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

@@ -1,6 +1,6 @@
<template>
<view class="bookings-page">
<!-- Tab filter -->
<!-- Tab bar -->
<view class="tab-bar">
<view
v-for="tab in tabs"
@@ -10,26 +10,30 @@
@tap="selectTab(tab.key)"
>
<text class="tab-label">{{ tab.label }}</text>
<view v-if="tab.key === 'upcoming' && upcomingCount > 0" class="tab-badge">
<text class="tab-badge-text">{{ upcomingCount }}</text>
</view>
</view>
</view>
<!-- Content -->
<!-- Upcoming tab content -->
<scroll-view
v-show="activeTab === 'upcoming'"
class="scroll"
scroll-y
refresher-enabled
:refresher-triggered="refreshing"
@refresherrefresh="onRefresh"
:refresher-triggered="refreshingUpcoming"
@refresherrefresh="onRefreshUpcoming"
>
<!-- Loading -->
<view v-if="bookingStore.loadingBookings && !refreshing" class="loading-wrap">
<view v-if="bookingStore.loadingBookings && !refreshingUpcoming" class="loading-wrap">
<view v-for="i in 3" :key="i" class="skeleton-card" />
</view>
<!-- Empty -->
<view v-else-if="filteredBookings.length === 0" class="empty-wrap">
<view v-else-if="upcomingBookings.length === 0" class="empty-wrap">
<text class="empty-icon">📅</text>
<text class="empty-title">暂无预约记录</text>
<text class="empty-title">暂无即将上课的预约</text>
<text class="empty-sub">去预约一节课吧</text>
<view class="empty-btn" @tap="goBooking">
<text class="empty-btn-text">去预约</text>
@@ -39,43 +43,81 @@
<!-- Booking list -->
<view v-else class="list">
<view
v-for="booking in filteredBookings"
v-for="booking in upcomingBookings"
:key="booking.id"
class="booking-card"
>
<!-- Date header stripe -->
<view class="booking-stripe" :class="stripeClass(booking.status)" />
<!-- Card content -->
<view class="booking-stripe stripe--confirmed" />
<view class="booking-content">
<view class="booking-main">
<!-- Date + time -->
<view class="booking-datetime">
<text class="booking-date">{{ formatDateDisplay(booking.timeSlot.date) }}</text>
<text class="booking-time">
{{ booking.timeSlot.startTime.slice(0, 5) }} - {{ booking.timeSlot.endTime.slice(0, 5) }}
{{ booking.timeSlot.startTime.slice(0, 5) }} {{ booking.timeSlot.endTime.slice(0, 5) }}
</text>
</view>
<view class="status-badge badge--confirmed">
<text class="status-text">已预约</text>
</view>
</view>
<view class="booking-meta">
<text class="meta-text">💳 {{ booking.membership.cardType.name }}</text>
</view>
<view class="cancel-row">
<view class="cancel-btn" @tap="handleCancel(booking)">
<text class="cancel-text">取消预约</text>
</view>
</view>
</view>
</view>
</view>
<!-- Status badge -->
<view class="scroll-bottom-spacer" />
</scroll-view>
<!-- History tab content -->
<scroll-view
v-show="activeTab === 'history'"
class="scroll"
scroll-y
refresher-enabled
:refresher-triggered="refreshingHistory"
@refresherrefresh="onRefreshHistory"
>
<!-- Loading -->
<view v-if="bookingStore.loadingBookings && !refreshingHistory" class="loading-wrap">
<view v-for="i in 3" :key="i" class="skeleton-card" />
</view>
<!-- Empty -->
<view v-else-if="historyBookings.length === 0" class="empty-wrap">
<text class="empty-icon">📋</text>
<text class="empty-title">暂无历史记录</text>
<text class="empty-sub">已完成或取消的课程将显示在这里</text>
</view>
<!-- Booking list -->
<view v-else class="list">
<view
v-for="booking in historyBookings"
:key="booking.id"
class="booking-card"
>
<view class="booking-stripe" :class="stripeClass(booking.status)" />
<view class="booking-content">
<view class="booking-main">
<view class="booking-datetime">
<text class="booking-date">{{ formatDateDisplay(booking.timeSlot.date) }}</text>
<text class="booking-time">
{{ booking.timeSlot.startTime.slice(0, 5) }} {{ booking.timeSlot.endTime.slice(0, 5) }}
</text>
</view>
<view class="status-badge" :class="statusBadgeClass(booking.status)">
<text class="status-text">{{ statusLabel(booking.status) }}</text>
</view>
</view>
<!-- Membership used -->
<view class="booking-meta">
<text class="meta-label">💳 {{ booking.membership.cardType.name }}</text>
</view>
<!-- Cancel button for confirmed upcoming bookings -->
<view
v-if="booking.status === BookingStatus.CONFIRMED && isUpcoming(booking.timeSlot.date)"
class="cancel-row"
>
<view class="cancel-btn" @tap="handleCancel(booking)">
<text class="cancel-text">取消预约</text>
</view>
<text class="meta-text">💳 {{ booking.membership.cardType.name }}</text>
</view>
</view>
</view>
@@ -91,44 +133,58 @@ import { ref, computed, onMounted } from 'vue'
import type { BookingWithDetails } from '@mp-pilates/shared'
import { BookingStatus } from '@mp-pilates/shared'
import { useBookingStore } from '../../stores/booking'
import { formatDate } from '../../utils/format'
import { formatDate, getWeekdayLabel } from '../../utils/format'
const bookingStore = useBookingStore()
// ─── Tab state ────────────────────────────────────────────
type TabKey = 'upcoming' | 'all'
type TabKey = 'upcoming' | 'history'
const tabs = [
{ key: 'upcoming' as TabKey, label: '即将上课' },
{ key: 'all' as TabKey, label: '全部记录' },
{ key: 'history' as TabKey, label: '历史记录' },
]
const activeTab = ref<TabKey>('upcoming')
const refreshing = ref(false)
const refreshingUpcoming = ref(false)
const refreshingHistory = ref(false)
// ─── Filtered bookings ────────────────────────────────────
const filteredBookings = computed<BookingWithDetails[]>(() => {
const today = computed(() => formatDate(new Date()))
const upcomingBookings = computed<BookingWithDetails[]>(() => {
const all = bookingStore.myBookings as BookingWithDetails[]
if (activeTab.value === 'upcoming') {
const today = formatDate(new Date())
return all.filter(
(b) => b.status === BookingStatus.CONFIRMED && b.timeSlot.date >= today,
).sort((a, b) => a.timeSlot.date.localeCompare(b.timeSlot.date))
}
return [...all].sort((a, b) => {
// Most recent first
if (b.timeSlot.date !== a.timeSlot.date) {
return b.timeSlot.date.localeCompare(a.timeSlot.date)
}
return b.timeSlot.startTime.localeCompare(a.timeSlot.startTime)
})
return all
.filter(
(b) => b.status === BookingStatus.CONFIRMED && b.timeSlot.date >= today.value,
)
.sort((a, b) => {
if (a.timeSlot.date !== b.timeSlot.date) {
return a.timeSlot.date.localeCompare(b.timeSlot.date)
}
return a.timeSlot.startTime.localeCompare(b.timeSlot.startTime)
})
})
// ─── Helpers ──────────────────────────────────────────────
function isUpcoming(date: string): boolean {
return date >= formatDate(new Date())
}
const historyBookings = computed<BookingWithDetails[]>(() => {
const all = bookingStore.myBookings as BookingWithDetails[]
return all
.filter(
(b) =>
b.status !== BookingStatus.CONFIRMED ||
b.timeSlot.date < today.value,
)
.sort((a, b) => {
if (b.timeSlot.date !== a.timeSlot.date) {
return b.timeSlot.date.localeCompare(a.timeSlot.date)
}
return b.timeSlot.startTime.localeCompare(a.timeSlot.startTime)
})
})
const upcomingCount = computed(() => upcomingBookings.value.length)
// ─── Helpers ──────────────────────────────────────────────
function statusLabel(status: BookingStatus): string {
const map: Record<BookingStatus, string> = {
[BookingStatus.CONFIRMED]: '已预约',
@@ -164,8 +220,7 @@ function formatDateDisplay(dateStr: string): string {
const d = new Date(dateStr)
const month = d.getMonth() + 1
const day = d.getDate()
const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
const weekday = weekdays[d.getDay()]
const weekday = getWeekdayLabel(d)
return `${month}${day}${weekday}`
}
@@ -174,10 +229,16 @@ function selectTab(key: TabKey) {
activeTab.value = key
}
async function onRefresh() {
refreshing.value = true
async function onRefreshUpcoming() {
refreshingUpcoming.value = true
await bookingStore.fetchMyBookings()
refreshing.value = false
refreshingUpcoming.value = false
}
async function onRefreshHistory() {
refreshingHistory.value = true
await bookingStore.fetchMyBookings()
refreshingHistory.value = false
}
function goBooking() {
@@ -185,25 +246,27 @@ function goBooking() {
}
async function handleCancel(booking: BookingWithDetails) {
const dateLabel = formatDateDisplay(booking.timeSlot.date)
const timeLabel = booking.timeSlot.startTime.slice(0, 5)
uni.showModal({
title: '取消预约',
content: `确定要取消 ${formatDateDisplay(booking.timeSlot.date)} ${booking.timeSlot.startTime.slice(0, 5)} 的课程吗?`,
content: `确定要取消 ${dateLabel} ${timeLabel} 的课程吗?`,
confirmText: '确定取消',
confirmColor: '#ef4444',
cancelText: '再想想',
success: async (res) => {
if (res.confirm) {
uni.showLoading({ title: '取消中...' })
try {
await bookingStore.cancelBooking(booking.id)
uni.hideLoading()
uni.showToast({ title: '已取消预约', icon: 'success' })
await bookingStore.fetchMyBookings()
} catch (err: unknown) {
uni.hideLoading()
const msg = err instanceof Error ? err.message : '取消失败,请重试'
uni.showToast({ title: msg, icon: 'none' })
}
if (!res.confirm) return
uni.showLoading({ title: '取消中...' })
try {
await bookingStore.cancelBooking(booking.id)
uni.hideLoading()
uni.showToast({ title: '已取消预约', icon: 'success' })
await bookingStore.fetchMyBookings()
} catch (err: unknown) {
uni.hideLoading()
const msg = err instanceof Error ? err.message : '取消失败,请重试'
uni.showToast({ title: msg, icon: 'none' })
}
},
})
@@ -227,16 +290,16 @@ onMounted(() => bookingStore.fetchMyBookings())
flex-direction: row;
background: #fff;
border-bottom: 1rpx solid #f0ece8;
position: sticky;
top: 0;
z-index: 10;
flex-shrink: 0;
}
.tab-item {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 8rpx;
padding: 28rpx 0;
position: relative;
@@ -252,7 +315,7 @@ onMounted(() => bookingStore.fetchMyBookings())
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 40rpx;
width: 48rpx;
height: 4rpx;
background: #c9a87c;
border-radius: 2rpx;
@@ -266,9 +329,27 @@ onMounted(() => bookingStore.fetchMyBookings())
font-weight: 400;
}
.tab-badge {
min-width: 32rpx;
height: 32rpx;
border-radius: 16rpx;
background: #ef4444;
display: flex;
align-items: center;
justify-content: center;
padding: 0 8rpx;
}
.tab-badge-text {
font-size: 20rpx;
color: #fff;
font-weight: 600;
}
/* ── Scroll ──────────────────────────────────────────── */
.scroll {
flex: 1;
height: calc(100vh - 88rpx);
}
/* ── Loading ─────────────────────────────────────────── */
@@ -348,14 +429,15 @@ onMounted(() => bookingStore.fetchMyBookings())
flex-direction: row;
}
/* Colored left stripe */
.booking-stripe {
width: 8rpx;
flex-shrink: 0;
&--confirmed { background: #c9a87c; }
&--completed { background: #4caf50; }
&--cancelled { background: #e0e0e0; }
&--noshow { background: #ef4444; }
&.stripe--confirmed { background: #c9a87c; }
&.stripe--completed { background: #4caf50; }
&.stripe--cancelled { background: #e0e0e0; }
&.stripe--noshow { background: #ef4444; }
}
.booking-content {
@@ -390,6 +472,7 @@ onMounted(() => bookingStore.fetchMyBookings())
color: #888;
}
/* Status badge */
.status-badge {
padding: 8rpx 18rpx;
border-radius: 20rpx;
@@ -411,14 +494,15 @@ onMounted(() => bookingStore.fetchMyBookings())
.badge--noshow & { color: #ef4444; }
}
/* Meta info */
.booking-meta {
.meta-label {
.meta-text {
font-size: 24rpx;
color: #999;
}
}
/* ── Cancel row ──────────────────────────────────────── */
/* Cancel row */
.cancel-row {
display: flex;
justify-content: flex-end;
@@ -426,13 +510,19 @@ onMounted(() => bookingStore.fetchMyBookings())
}
.cancel-btn {
padding: 8rpx 24rpx;
padding: 10rpx 24rpx;
border-radius: 24rpx;
border: 1rpx solid #ef444430;
background: #fef0f0;
&:active {
opacity: 0.75;
}
}
.cancel-text {
font-size: 24rpx;
color: #ef4444;
text-decoration: underline;
font-weight: 500;
}