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 @@ @@ -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(() => {