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

@@ -14,7 +14,7 @@
</view>
<!-- Empty state -->
<view v-else-if="memberships.length === 0" class="empty-wrap">
<view v-else-if="allMemberships.length === 0" class="empty-wrap">
<text class="empty-icon">💳</text>
<text class="empty-title">暂无会员卡</text>
<text class="empty-sub">购买会员卡后即可预约课程</text>
@@ -26,27 +26,59 @@
<!-- Membership list -->
<view v-else class="list">
<!-- Active cards -->
<view v-if="activeMemberships.length > 0">
<text class="group-title">有效会员卡</text>
<view v-if="activeMemberships.length > 0" class="group-section">
<view class="group-header">
<view class="group-dot group-dot--active" />
<text class="group-title">有效会员卡</text>
<text class="group-count">{{ activeMemberships.length }} </text>
</view>
<view
v-for="m in activeMemberships"
:key="m.id"
class="card-item card-item--active"
class="card-item"
>
<view class="card-top" :class="cardTopClass(m)">
<view>
<!-- Colored left border strip -->
<view class="card-strip" :class="stripClass(m.cardType.type)" />
<!-- Card header (colored gradient) -->
<view class="card-header" :class="headerClass(m.cardType.type)">
<view class="card-header-left">
<text class="card-name">{{ m.cardType.name }}</text>
<text class="card-type-tag">{{ typeLabel(m.cardType.type) }}</text>
<view class="card-type-badge">
<text class="card-type-badge-text">{{ typeLabel(m.cardType.type) }}</text>
</view>
</view>
<view class="card-badge card-badge--active">
<text class="badge-text">有效</text>
<view class="status-badge status-badge--active">
<text class="status-badge-text">有效</text>
</view>
</view>
<!-- Card body -->
<view class="card-body">
<view class="info-row" v-if="m.remainingTimes !== null">
<text class="info-label">剩余课时</text>
<text class="info-value info-value--highlight">{{ m.remainingTimes }} </text>
</view>
<!-- Times card: remaining times + progress -->
<template v-if="m.remainingTimes !== null">
<view class="highlight-row">
<text class="highlight-label">剩余课时</text>
<text class="highlight-value">
<text class="highlight-number">{{ m.remainingTimes }}</text>
<text class="highlight-unit"> </text>
</text>
</view>
<view v-if="m.cardType.totalTimes" class="progress-wrap">
<view class="progress-bar">
<view
class="progress-fill"
:style="{ width: progressWidth(m) }"
/>
</view>
<text class="progress-label">
已使用 {{ usedTimes(m) }} / {{ m.cardType.totalTimes }}
</text>
</view>
</template>
<!-- Duration card: expiry -->
<view class="info-row">
<text class="info-label">有效期至</text>
<text class="info-value">{{ m.expireDate.slice(0, 10) }}</text>
@@ -56,40 +88,36 @@
<text class="info-value">{{ m.startDate.slice(0, 10) }}</text>
</view>
</view>
<!-- Progress bar for time-based cards -->
<view v-if="m.remainingTimes !== null && m.cardType.totalTimes" class="progress-wrap">
<view class="progress-bar">
<view
class="progress-fill"
:style="{ width: progressWidth(m) }"
/>
</view>
<text class="progress-label">
已使用 {{ m.cardType.totalTimes - m.remainingTimes }}/{{ m.cardType.totalTimes }}
</text>
</view>
</view>
</view>
<!-- Expired / used up cards -->
<view v-if="inactiveMemberships.length > 0" class="inactive-section">
<text class="group-title">历史记录</text>
<view v-if="inactiveMemberships.length > 0" class="group-section">
<view class="group-header">
<view class="group-dot group-dot--inactive" />
<text class="group-title">历史记录</text>
<text class="group-count">{{ inactiveMemberships.length }} </text>
</view>
<view
v-for="m in inactiveMemberships"
:key="m.id"
class="card-item card-item--inactive"
>
<view class="card-top card-top--inactive">
<view>
<view class="card-strip card-strip--inactive" />
<view class="card-header card-header--inactive">
<view class="card-header-left">
<text class="card-name card-name--dim">{{ m.cardType.name }}</text>
<text class="card-type-tag card-type-tag--dim">{{ typeLabel(m.cardType.type) }}</text>
<view class="card-type-badge card-type-badge--dim">
<text class="card-type-badge-text">{{ typeLabel(m.cardType.type) }}</text>
</view>
</view>
<view class="card-badge" :class="statusBadgeClass(m.status)">
<text class="badge-text">{{ statusLabel(m.status) }}</text>
<view class="status-badge" :class="statusBadgeClass(m.status)">
<text class="status-badge-text">{{ statusLabel(m.status) }}</text>
</view>
</view>
<view class="card-body">
<view class="info-row" v-if="m.remainingTimes !== null">
<view v-if="m.remainingTimes !== null" class="info-row">
<text class="info-label">剩余课时</text>
<text class="info-value">{{ m.remainingTimes }} </text>
</view>
@@ -107,7 +135,8 @@
<!-- Buy more FAB -->
<view class="fab" @tap="goStore">
<text class="fab-text">+ 购买会员卡</text>
<text class="fab-icon">+</text>
<text class="fab-text">购买会员卡</text>
</view>
</view>
</template>
@@ -116,20 +145,23 @@
import { ref, computed, onMounted } from 'vue'
import type { MembershipWithCardType } from '@mp-pilates/shared'
import { MembershipStatus, CardTypeCategory } from '@mp-pilates/shared'
import { get } from '../../utils/request'
import { useUserStore } from '../../stores/user'
const userStore = useUserStore()
// ─── State ────────────────────────────────────────────────
const memberships = ref<MembershipWithCardType[]>([])
const loading = ref(false)
const refreshing = ref(false)
// ─── Computed ─────────────────────────────────────────────
// ─── Computed from store ───────────────────────────────────
const allMemberships = computed(() => userStore.memberships as MembershipWithCardType[])
const activeMemberships = computed(() =>
memberships.value.filter((m) => m.status === MembershipStatus.ACTIVE),
allMemberships.value.filter((m) => m.status === MembershipStatus.ACTIVE),
)
const inactiveMemberships = computed(() =>
memberships.value.filter((m) => m.status !== MembershipStatus.ACTIVE),
allMemberships.value.filter((m) => m.status !== MembershipStatus.ACTIVE),
)
// ─── Helpers ──────────────────────────────────────────────
@@ -152,15 +184,21 @@ function statusLabel(status: MembershipStatus): string {
}
function statusBadgeClass(status: MembershipStatus): string {
if (status === MembershipStatus.EXPIRED) return 'card-badge--expired'
if (status === MembershipStatus.USED_UP) return 'card-badge--used'
return ''
if (status === MembershipStatus.EXPIRED) return 'status-badge--expired'
if (status === MembershipStatus.USED_UP) return 'status-badge--used'
return 'status-badge--expired'
}
function cardTopClass(m: MembershipWithCardType): string {
if (m.cardType.type === CardTypeCategory.TRIAL) return 'card-top--trial'
if (m.cardType.type === CardTypeCategory.DURATION) return 'card-top--duration'
return 'card-top--times'
function stripClass(type: CardTypeCategory): string {
if (type === CardTypeCategory.TRIAL) return 'card-strip--trial'
if (type === CardTypeCategory.DURATION) return 'card-strip--duration'
return 'card-strip--times'
}
function headerClass(type: CardTypeCategory): string {
if (type === CardTypeCategory.TRIAL) return 'card-header--trial'
if (type === CardTypeCategory.DURATION) return 'card-header--duration'
return 'card-header--times'
}
function progressWidth(m: MembershipWithCardType): string {
@@ -169,11 +207,16 @@ function progressWidth(m: MembershipWithCardType): string {
return `${Math.max(0, Math.min(100, pct))}%`
}
function usedTimes(m: MembershipWithCardType): number {
if (m.remainingTimes === null || !m.cardType.totalTimes) return 0
return m.cardType.totalTimes - m.remainingTimes
}
// ─── Data loading ─────────────────────────────────────────
async function loadMemberships() {
loading.value = true
try {
memberships.value = await get<MembershipWithCardType[]>('/membership/my')
await userStore.fetchMemberships()
} catch {
uni.showToast({ title: '加载失败,请下拉刷新', icon: 'none' })
} finally {
@@ -183,13 +226,11 @@ async function loadMemberships() {
async function onRefresh() {
refreshing.value = true
await loadMemberships()
await userStore.fetchMemberships()
refreshing.value = false
}
function goStore() {
uni.navigateBack({ delta: 10 })
// Navigate to store tab
uni.switchTab({ url: '/pages/home/index' })
}
@@ -216,7 +257,7 @@ onMounted(loadMemberships)
}
.skeleton-card {
height: 200rpx;
height: 220rpx;
border-radius: 20rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
background-size: 400% 100%;
@@ -255,9 +296,10 @@ onMounted(loadMemberships)
.empty-btn {
margin-top: 12rpx;
padding: 20rpx 56rpx;
padding: 22rpx 60rpx;
border-radius: 44rpx;
background: #c9a87c;
box-shadow: 0 4rpx 16rpx rgba(201, 168, 124, 0.35);
}
.empty-btn-text {
@@ -269,17 +311,41 @@ onMounted(loadMemberships)
/* ── List ────────────────────────────────────────────── */
.list {
padding: 24rpx 24rpx 0;
}
/* ── Group section ───────────────────────────────────── */
.group-section {
margin-bottom: 8rpx;
}
.group-header {
display: flex;
flex-direction: column;
gap: 8rpx;
flex-direction: row;
align-items: center;
gap: 10rpx;
padding: 8rpx 4rpx 14rpx;
}
.group-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
flex-shrink: 0;
&--active { background: #4caf50; }
&--inactive { background: #bbb; }
}
.group-title {
font-size: 26rpx;
color: #999;
font-weight: 500;
padding: 8rpx 4rpx 12rpx;
display: block;
color: #555;
font-weight: 600;
flex: 1;
}
.group-count {
font-size: 22rpx;
color: #bbb;
}
/* ── Card item ───────────────────────────────────────── */
@@ -288,15 +354,28 @@ onMounted(loadMemberships)
border-radius: 20rpx;
overflow: hidden;
margin-bottom: 16rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.07);
display: flex;
flex-direction: column;
&--inactive {
opacity: 0.75;
opacity: 0.72;
}
}
.card-top {
padding: 24rpx 28rpx;
/* Colored left border strip */
.card-strip {
height: 6rpx;
&--times { background: linear-gradient(90deg, #1a1a2e, #2d2d5e); }
&--duration { background: linear-gradient(90deg, #6c3483, #9b59b6); }
&--trial { background: linear-gradient(90deg, #7d6608, #c9a87c); }
&--inactive { background: #ccc; }
}
/* Card header gradient area */
.card-header {
padding: 22rpx 28rpx;
display: flex;
flex-direction: row;
align-items: center;
@@ -308,46 +387,88 @@ onMounted(loadMemberships)
&--inactive { background: #888; }
}
.card-header-left {
display: flex;
flex-direction: column;
gap: 8rpx;
}
.card-name {
font-size: 32rpx;
font-weight: 700;
color: #fff;
display: block;
margin-bottom: 6rpx;
&--dim { color: #ddd; }
}
.card-type-tag {
font-size: 20rpx;
color: rgba(255, 255, 255, 0.7);
font-weight: 400;
display: block;
.card-type-badge {
align-self: flex-start;
padding: 4rpx 14rpx;
border-radius: 12rpx;
background: rgba(255, 255, 255, 0.15);
border: 1rpx solid rgba(255, 255, 255, 0.25);
&--dim { color: rgba(255, 255, 255, 0.5); }
&--dim {
background: rgba(255, 255, 255, 0.08);
}
}
.card-badge {
.card-type-badge-text {
font-size: 20rpx;
color: rgba(255, 255, 255, 0.85);
font-weight: 500;
}
/* Status badge */
.status-badge {
padding: 8rpx 20rpx;
border-radius: 20rpx;
border: 1rpx solid rgba(255, 255, 255, 0.4);
border: 1rpx solid rgba(255, 255, 255, 0.35);
flex-shrink: 0;
&--active { background: rgba(76, 175, 80, 0.25); }
&--active { background: rgba(76, 175, 80, 0.3); }
&--expired { background: rgba(0, 0, 0, 0.2); }
&--used { background: rgba(0, 0, 0, 0.2); }
}
.badge-text {
.status-badge-text {
font-size: 22rpx;
color: #fff;
font-weight: 600;
}
/* Card body */
.card-body {
padding: 20rpx 28rpx;
padding: 20rpx 28rpx 24rpx;
display: flex;
flex-direction: column;
gap: 12rpx;
gap: 10rpx;
}
.highlight-row {
display: flex;
flex-direction: row;
align-items: baseline;
justify-content: space-between;
margin-bottom: 4rpx;
}
.highlight-label {
font-size: 26rpx;
color: #999;
}
.highlight-number {
font-size: 44rpx;
font-weight: 800;
color: #c9a87c;
line-height: 1;
}
.highlight-unit {
font-size: 22rpx;
color: #c9a87c;
font-weight: 500;
}
.info-row {
@@ -366,20 +487,14 @@ onMounted(loadMemberships)
font-size: 26rpx;
color: #333;
font-weight: 500;
&--highlight {
color: #c9a87c;
font-size: 30rpx;
font-weight: 700;
}
}
/* ── Progress bar ────────────────────────────────────── */
.progress-wrap {
padding: 0 28rpx 20rpx;
display: flex;
flex-direction: column;
gap: 8rpx;
margin-bottom: 6rpx;
}
.progress-bar {
@@ -402,11 +517,6 @@ onMounted(loadMemberships)
text-align: right;
}
/* ── Inactive section ────────────────────────────────── */
.inactive-section {
margin-top: 8rpx;
}
/* ── FAB ─────────────────────────────────────────────── */
.fab {
position: fixed;
@@ -417,12 +527,23 @@ onMounted(loadMemberships)
padding: 22rpx 36rpx;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.2);
z-index: 100;
display: flex;
flex-direction: row;
align-items: center;
gap: 8rpx;
&:active {
opacity: 0.85;
}
}
.fab-icon {
font-size: 36rpx;
color: #c9a87c;
font-weight: 300;
line-height: 1;
}
.fab-text {
font-size: 28rpx;
font-weight: 700;
@@ -432,6 +553,6 @@ onMounted(loadMemberships)
/* ── Spacer ──────────────────────────────────────────── */
.scroll-bottom-spacer {
height: 100rpx;
height: 120rpx;
}
</style>