diff --git a/packages/app/src/components/LessonSupplementList.vue b/packages/app/src/components/LessonSupplementList.vue
index c17d786..a672c21 100644
--- a/packages/app/src/components/LessonSupplementList.vue
+++ b/packages/app/src/components/LessonSupplementList.vue
@@ -23,9 +23,10 @@ defineProps<{ records: readonly LessonSupplementRecord[]; editable?: boolean; bu
const emit = defineEmits<{ revoke: [record: LessonSupplementRecord] }>()
diff --git a/packages/app/src/pages/profile/bookings.vue b/packages/app/src/pages/profile/bookings.vue
index c9e1557..3bfe0ed 100644
--- a/packages/app/src/pages/profile/bookings.vue
+++ b/packages/app/src/pages/profile/bookings.vue
@@ -1,23 +1,20 @@
-
+
-
-
+
+
- {{ tab.label }}
-
- {{ upcomingCount }}
-
+ {{ tab.label }}
+ {{ upcomingCount }}
-
-
-
-
-
-
-
-
-
+
+
+
+
+ —
+ 暂无即将上课的预约
+ 去课表选一个时段即可。
+
+ 去预约
-
-
-
-
-
-
-
- 暂无即将上课的预约
- 开始预约你的普拉提课程吧
-
- 立即预约
-
-
-
-
-
-
-
-
-
-
- 历史课程补录
+
+ 历史补录
正在加载补录记录…
-
+
-
+
-
-
-
-
-
+
+
+
+
+ —
+ 暂无历史记录
+ 上完或取消的课程会显示在这里。
+
+
+
+
+
+ {{ group.label }}
+ {{ group.items.length }} 节
-
-
-
-
-
-
-
-
-
- 暂无历史记录
- 已完成或取消的课程将显示在这里
-
-
-
-
-
-
-
-
-
+
@@ -173,29 +144,31 @@ import { onShow } from '@dcloudio/uni-app'
import type { BookingWithDetails, LessonSupplementRecord } from '@mp-pilates/shared'
import { BookingStatus } from '@mp-pilates/shared'
import { useBookingStore } from '../../stores/booking'
-import { formatDate, getWeekdayLabel } from '../../utils/format'
+import { formatDate } from '../../utils/format'
+import { getSystemLayout } from '../../utils/system'
+import { get } from '../../utils/request'
+import {
+ formatDateDisplay,
+ bookingStatusLabel,
+ bookingStatusStripeClass,
+} from '../../utils/booking-helpers'
import CustomNavBar from '../../components/CustomNavBar.vue'
import LessonSupplementList from '../../components/LessonSupplementList.vue'
-import { get } from '../../utils/request'
+
+type TabKey = 'upcoming' | 'history'
+
+interface BookingGroup {
+ key: string
+ label: string
+ items: BookingWithDetails[]
+}
const bookingStore = useBookingStore()
const supplements = ref([])
const supplementsLoading = ref(false)
const supplementsError = ref(false)
-async function fetchSupplements() {
- supplementsLoading.value = true
- supplementsError.value = false
- try { supplements.value = await get('/user/lesson-supplements') }
- catch { supplementsError.value = true }
- finally { supplementsLoading.value = false }
-}
-
-// ─── Nav bar height ──────────────────────────────────────
-const navBarHeight = ref('64px')
-
-// ─── Tab state ────────────────────────────────────────────
-type TabKey = 'upcoming' | 'history'
+const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
const tabs = [
{ key: 'upcoming' as TabKey, label: '即将上课' },
@@ -205,117 +178,129 @@ const tabs = [
const activeTab = ref('upcoming')
const refreshingUpcoming = ref(false)
const refreshingHistory = ref(false)
+const hasLoadedOnce = ref(false)
+
+async function fetchSupplements() {
+ supplementsLoading.value = true
+ supplementsError.value = false
+ try {
+ supplements.value = await get('/user/lesson-supplements')
+ } catch {
+ supplementsError.value = true
+ } finally {
+ supplementsLoading.value = false
+ }
+}
-// ─── Safe array accessor ─────────────────────────────────
function safeBookings(): readonly BookingWithDetails[] {
const raw = bookingStore.myBookings
return Array.isArray(raw) ? raw : []
}
-/** Normalize date to YYYY-MM-DD — handles both "2026-04-06" and "2026-04-06T00:00:00.000Z" */
function toDateStr(date: string): string {
return date.slice(0, 10)
}
-// ─── Filtered bookings ────────────────────────────────────
+function isUpcomingBooking(booking: BookingWithDetails, todayStr: string): boolean {
+ const upcomingStatus =
+ booking.status === BookingStatus.PENDING_CONFIRMATION ||
+ booking.status === BookingStatus.CONFIRMED
+ return upcomingStatus && toDateStr(booking.timeSlot.date) >= todayStr
+}
+
const today = computed(() => formatDate(new Date()))
const upcomingBookings = computed(() => {
return safeBookings()
- .filter(
- (b) =>
- (b.status === BookingStatus.PENDING_CONFIRMATION || b.status === BookingStatus.CONFIRMED) &&
- toDateStr(b.timeSlot.date) >= today.value,
- )
+ .filter((booking) => isUpcomingBooking(booking, today.value))
.sort((a, b) => {
const dateA = toDateStr(a.timeSlot.date)
const dateB = toDateStr(b.timeSlot.date)
- if (dateA !== dateB) {
- return dateA.localeCompare(dateB)
- }
+ if (dateA !== dateB) return dateA.localeCompare(dateB)
return a.timeSlot.startTime.localeCompare(b.timeSlot.startTime)
})
})
const historyBookings = computed(() => {
return safeBookings()
- .filter(
- (b) =>
- b.status !== BookingStatus.CONFIRMED ||
- toDateStr(b.timeSlot.date) < today.value,
- )
+ .filter((booking) => !isUpcomingBooking(booking, today.value))
.sort((a, b) => {
const dateA = toDateStr(a.timeSlot.date)
const dateB = toDateStr(b.timeSlot.date)
- if (dateB !== dateA) {
- return dateB.localeCompare(dateA)
- }
+ if (dateB !== dateA) return dateB.localeCompare(dateA)
return b.timeSlot.startTime.localeCompare(a.timeSlot.startTime)
})
})
const upcomingCount = computed(() => upcomingBookings.value.length)
-// ─── Helpers ──────────────────────────────────────────────
-const STATUS_LABELS: Record = {
- [BookingStatus.PENDING_CONFIRMATION]: '待确认',
- [BookingStatus.CONFIRMED]: '已预约',
- [BookingStatus.CANCELLED]: '已取消',
- [BookingStatus.COMPLETED]: '已完成',
- [BookingStatus.NO_SHOW]: '未出席',
-}
+const upcomingGroups = computed(() =>
+ groupBookings(
+ upcomingBookings.value,
+ (booking) => toDateStr(booking.timeSlot.date),
+ (key) => formatDateDisplay(key),
+ ),
+)
-const STATUS_BADGE_CLASSES: Record = {
- [BookingStatus.PENDING_CONFIRMATION]: 'badge--pending',
- [BookingStatus.CONFIRMED]: 'badge--confirmed',
- [BookingStatus.CANCELLED]: 'badge--cancelled',
- [BookingStatus.COMPLETED]: 'badge--completed',
- [BookingStatus.NO_SHOW]: 'badge--noshow',
-}
+const historyGroups = computed(() =>
+ groupBookings(
+ historyBookings.value,
+ (booking) => toDateStr(booking.timeSlot.date).slice(0, 7),
+ (key) => {
+ const [year, month] = key.split('-')
+ return `${year}年${Number(month)}月`
+ },
+ ),
+)
-const STATUS_STRIPE_CLASSES: Record = {
- [BookingStatus.PENDING_CONFIRMATION]: 'stripe--pending',
- [BookingStatus.CONFIRMED]: 'stripe--confirmed',
- [BookingStatus.CANCELLED]: 'stripe--cancelled',
- [BookingStatus.COMPLETED]: 'stripe--completed',
- [BookingStatus.NO_SHOW]: 'stripe--noshow',
-}
-
-function statusLabel(status: string): string {
- return STATUS_LABELS[status] ?? status
-}
-
-function statusBadgeClass(status: string): string {
- return STATUS_BADGE_CLASSES[status] ?? ''
-}
-
-function stripeClass(status: string): string {
- return STATUS_STRIPE_CLASSES[status] ?? ''
-}
-
-function formatDateDisplay(dateStr: string): string {
- const normalized = toDateStr(dateStr)
- const todayStr = formatDate(new Date())
- const tomorrowDate = new Date()
- tomorrowDate.setDate(tomorrowDate.getDate() + 1)
- const tomorrowStr = formatDate(tomorrowDate)
-
- // Parse from normalized YYYY-MM-DD to avoid timezone shifts
- const [y, m, d] = normalized.split('-').map(Number)
- const localDate = new Date(y, m - 1, d)
- const weekday = getWeekdayLabel(localDate)
-
- if (normalized === todayStr) {
- return `今天 ${m}月${d}日`
+function groupBookings(
+ bookings: BookingWithDetails[],
+ getKey: (booking: BookingWithDetails) => string,
+ getLabel: (key: string) => string,
+): BookingGroup[] {
+ const map = new Map()
+ for (const booking of bookings) {
+ const key = getKey(booking)
+ const list = map.get(key)
+ if (list) list.push(booking)
+ else map.set(key, [booking])
}
- if (normalized === tomorrowStr) {
- return `明天 ${m}月${d}日`
- }
- return `${m}月${d}日 ${weekday}`
+ return Array.from(map, ([key, items]) => ({
+ key,
+ label: getLabel(key),
+ items,
+ }))
}
-// ─── Actions ──────────────────────────────────────────────
-const hasLoadedOnce = ref(false)
+function startTime(booking: BookingWithDetails): string {
+ return booking.timeSlot.startTime.slice(0, 5)
+}
+
+function endTime(booking: BookingWithDetails): string {
+ return booking.timeSlot.endTime.slice(0, 5)
+}
+
+function cardName(booking: BookingWithDetails): string {
+ return booking.membership?.cardType?.name || '会员卡'
+}
+
+function historyDayLabel(dateStr: string): string {
+ const [year, month, day] = toDateStr(dateStr).split('-').map(Number)
+ const weekday = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][new Date(year, month - 1, day).getDay()]
+ return `${day}日 ${weekday}`
+}
+
+function stampClass(status: string): string {
+ if (status === BookingStatus.PENDING_CONFIRMATION) return 'stamp--pending'
+ if (status === BookingStatus.CANCELLED) return 'stamp--cancelled'
+ if (status === BookingStatus.NO_SHOW) return 'stamp--noshow'
+ if (status === BookingStatus.COMPLETED) return 'stamp--completed'
+ return 'stamp--confirmed'
+}
+
+function isMutedStatus(status: string): boolean {
+ return status === BookingStatus.CANCELLED || status === BookingStatus.NO_SHOW
+}
function selectTab(key: TabKey) {
activeTab.value = key
@@ -343,7 +328,7 @@ function goDetail(booking: BookingWithDetails) {
async function handleCancel(booking: BookingWithDetails) {
const dateLabel = formatDateDisplay(booking.timeSlot.date)
- const timeLabel = booking.timeSlot.startTime.slice(0, 5)
+ const timeLabel = startTime(booking)
uni.showModal({
title: '取消预约',
@@ -368,22 +353,14 @@ async function handleCancel(booking: BookingWithDetails) {
})
}
-// ─── Lifecycle ────────────────────────────────────────────
onMounted(() => {
+ navBarHeight.value = `${getSystemLayout().navBarHeight}px`
fetchSupplements()
- const windowInfo = uni.getWindowInfo()
- const statusBarH = windowInfo.statusBarHeight ?? 20
- navBarHeight.value = `${statusBarH + Math.round(88 * windowInfo.windowWidth / 750)}px`
bookingStore.fetchMyBookings().then(() => {
hasLoadedOnce.value = true
})
})
-// After returning from booking detail (where status may have changed),
-// silently re-sync without flipping loading state — keeps the list visible
-// and avoids the skeleton flash. Store action `replaceBooking` already
-// keeps `myBookings` in sync for the common case; this is the safety net
-// for any state we didn't locally patch (e.g. server-side cascading fields).
onShow(() => {
if (!hasLoadedOnce.value) return
fetchSupplements()
@@ -392,378 +369,306 @@ onShow(() => {