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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user