## Features ### Admin Schedule Page (`packages/app/src/pages/admin/schedule.vue`) - Interactive date-based slot editor for managing daily schedules - Real-time slot editing: start/end times, capacity adjustments - Slot deletion with conflict warnings when bookings exist - Add new slots with modal dialog - Live booking status display (booked count, people names) - Publish/Save changes with sync feedback - Revert unsaved changes with confirmation - Skeleton loading states and empty state handling - Responsive design with optimized mobile UX ### Backend Enhancements - **New DTO** (`PublishDaySlotsDto`): Structured slot publishing with validation - Date string validation - Slot array with existing slot IDs for updates - Time and capacity validation per slot - **Schedule Preview API** (`getSchedulePreview`): - Check for existing published slots - Fallback to active WeekTemplates for unpublished dates - Unified response format with isPublished flag - **Publish Slots API** (`publishDaySlots`): - Atomic transaction for consistency - Update existing slots with new times/capacity - Create new slots from template data - Delete unpublished slots or set to CLOSED if bookings exist - Prevent capacity reduction below existing bookings - Returns all published slots for feedback ### State Management - Enhanced admin store with schedule state - Support for pending/unsaved slot changes - Optimistic UI updates with server sync ### Documentation - Comprehensive scheduling system architecture docs - Quick reference for admin workflows - Flow diagrams and state transitions - Implementation guide for future maintenance ## Breaking Changes None ## Testing Recommendations - Create slots for future dates via schedule editor - Verify booking prevention for locked/full slots - Test capacity adjustments with existing bookings - Confirm template-based schedule generation - Verify transaction rollback on publish failures Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
553 lines
33 KiB
Markdown
553 lines
33 KiB
Markdown
# Booking Page - Architecture Diagram
|
|
|
|
## 🏛️ Complete System Architecture
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
|
│ WECHAT MINI-PROGRAM │
|
|
│ │
|
|
│ ┌──────────────────────────────────────────────────────────────────────┐ │
|
|
│ │ FRONTEND (Vue 3 + Uni-app) │ │
|
|
│ │ │ │
|
|
│ │ ┌────────────────────────────────────────────────────────────────┐ │ │
|
|
│ │ │ pages/booking/index.vue (Main Page Component) │ │ │
|
|
│ │ │ ───────────────────────────────────────────────────────────── │ │ │
|
|
│ │ │ State: │ │ │
|
|
│ │ │ • selectedDate: string │ │ │
|
|
│ │ │ • selectedPeriod: PeriodKey | null │ │ │
|
|
│ │ │ • showConfirmPopup: boolean │ │ │
|
|
│ │ │ • pendingSlot: TimeSlotWithBookingStatus | null │ │ │
|
|
│ │ │ • refreshing: boolean │ │ │
|
|
│ │ │ │ │ │
|
|
│ │ │ Computed: │ │ │
|
|
│ │ │ • scrollHeight (responsive) │ │ │
|
|
│ │ │ • filteredSlots (depends on period) │ │ │
|
|
│ │ │ │ │ │
|
|
│ │ │ Lifecycle: │ │ │
|
|
│ │ │ • onMounted() → Load memberships + today's slots │ │ │
|
|
│ │ │ │ │ │
|
|
│ │ │ Event Handlers: │ │ │
|
|
│ │ │ • onDateSelect() → loadSlots(newDate) │ │ │
|
|
│ │ │ • onPeriodChange() → Auto-filter via computed │ │ │
|
|
│ │ │ • onRefresh() → Reload slots │ │ │
|
|
│ │ │ • onBookTap() → Auth check → Show popup │ │ │
|
|
│ │ │ • onConfirmBooking() → Create booking → Refresh │ │ │
|
|
│ │ │ • onCancelTap() → Cancel booking → Refresh │ │ │
|
|
│ │ └────────────────────────────────────────────────────────────────┘ │ │
|
|
│ │ ↓ │ │
|
|
│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │
|
|
│ │ │ Child Components (All reactive & event-driven) │ │ │
|
|
│ │ │ │ │ │
|
|
│ │ │ ┌──────────────────┐ ┌──────────────────┐ │ │ │
|
|
│ │ │ │ DateSelector.vue │ │ TimePeriod...vue │ │ │ │
|
|
│ │ │ │ │ │ │ │ │ │
|
|
│ │ │ │ [Today] [5] [4] │ │ 全部 上午 下午... │ │ │ │
|
|
│ │ │ │ Props: modelValue│ │ Props: modelValue│ │ │ │
|
|
│ │ │ │ Emit: @select │ │ Emit: @change │ │ │ │
|
|
│ │ │ └──────────────────┘ └──────────────────┘ │ │ │
|
|
│ │ │ ↓ ↓ │ │ │
|
|
│ │ │ (Updates selectedDate) (Updates selectedPeriod) │ │ │
|
|
│ │ │ ↓ ↓ │ │ │
|
|
│ │ │ (Triggers loadSlots) (Recomputes filteredSlots) │ │ │
|
|
│ │ │ │ │ │
|
|
│ │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │
|
|
│ │ │ │ SlotCard.vue (Rendered via v-for over filteredSlots) │ │ │ │
|
|
│ │ │ │ │ │ │ │
|
|
│ │ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ │
|
|
│ │ │ │ │ [09:00-10:00] [0/1 人] │ │ │ │ │
|
|
│ │ │ │ │ [可预约] │ │ │ │ │
|
|
│ │ │ │ │ │ │ │ │ │
|
|
│ │ │ │ │ Props: slot (TimeSlotWithBookingStatus) │ │ │ │ │
|
|
│ │ │ │ │ Emit: @book | @cancel │ │ │ │ │
|
|
│ │ │ │ │ │ │ │ │ │
|
|
│ │ │ │ │ Computed: │ │ │ │ │
|
|
│ │ │ │ │ • capacityLabel ("0/1 人" | "已关闭") │ │ │ │ │
|
|
│ │ │ │ │ • capacityClass (cap-open | cap-almost | ...) │ │ │ │ │
|
|
│ │ │ │ │ │ │ │ │ │
|
|
│ │ │ │ │ Button States (4 conditions): │ │ │ │ │
|
|
│ │ │ │ │ 1. OPEN + not booked → "可预约" │ │ │ │ │
|
|
│ │ │ │ │ 2. OPEN + booked → "已预约" + "取消" │ │ │ │ │
|
|
│ │ │ │ │ 3. FULL → "已约满" │ │ │ │ │
|
|
│ │ │ │ │ 4. CLOSED → "已关闭" │ │ │ │ │
|
|
│ │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ │
|
|
│ │ │ │ ↓ ↓ │ │ │ │
|
|
│ │ │ │ (onBookTap) (onCancelTap) │ │ │ │
|
|
│ │ │ │ │ │ │ │
|
|
│ │ │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ │
|
|
│ │ │ │ │ BookingConfirmPopup.vue (Modal) │ │ │ │ │
|
|
│ │ │ │ │ │ │ │ │ │
|
|
│ │ │ │ │ Props: │ │ │ │ │
|
|
│ │ │ │ │ • visible: boolean │ │ │ │ │
|
|
│ │ │ │ │ • slot: TimeSlotWithBookingStatus │ │ │ │ │
|
|
│ │ │ │ │ • memberships: MembershipWithCardType[] │ │ │ │ │
|
|
│ │ │ │ │ │ │ │ │ │
|
|
│ │ │ │ │ State: │ │ │ │ │
|
|
│ │ │ │ │ • selectedMembershipId (auto-selected on show) │ │ │ │ │
|
|
│ │ │ │ │ │ │ │ │ │
|
|
│ │ │ │ │ Display: │ │ │ │ │
|
|
│ │ │ │ │ ┌─────────────────────────────────┐ │ │ │ │ │
|
|
│ │ │ │ │ │ 确认预约 ✕ │ │ │ │ │
|
|
│ │ │ │ │ ├─────────────────────────────────┤ │ │ │ │ │
|
|
│ │ │ │ │ │ 日期: 2026-04-05 │ │ │ │ │ │
|
|
│ │ │ │ │ │ 时间: 09:00 - 10:00 │ │ │ │ │ │
|
|
│ │ │ │ │ │ 剩余: 1 个名额 │ │ │ │ │ │
|
|
│ │ │ │ │ ├─────────────────────────────────┤ │ │ │ │ │
|
|
│ │ │ │ │ │ 💳 私教课程 │ │ │ │ │ │
|
|
│ │ │ │ │ │ 剩余 10 次 ✓ │ │ │ │ │ │
|
|
│ │ │ │ │ ├─────────────────────────────────┤ │ │ │ │ │
|
|
│ │ │ │ │ │ [取消] [确认预约] │ │ │ │ │ │
|
|
│ │ │ │ │ └─────────────────────────────────┘ │ │ │ │ │
|
|
│ │ │ │ │ ↓ │ │ │ │ │
|
|
│ │ │ │ │ Emit: @confirm({timeSlotId, membershipId}) │ │ │ │ │
|
|
│ │ │ │ │ or @cancel │ │ │ │ │
|
|
│ │ │ │ └──────────────────────────────────────────────────────┘ │ │ │ │
|
|
│ │ │ └──────────────────────────────────────────────────────────┘ │ │ │
|
|
│ │ └────────────────────────────────────────────────────────────────┘ │ │
|
|
│ │ ↓ │ │
|
|
│ │ ┌────────────────────────────────────────────────────────────────┐ │ │
|
|
│ │ │ Pinia Stores (Reactive State Management) │ │ │
|
|
│ │ │ │ │ │
|
|
│ │ │ stores/booking.ts: │ │ │
|
|
│ │ │ State: │ │ │
|
|
│ │ │ • slots: TimeSlotWithBookingStatus[] │ │ │
|
|
│ │ │ • myBookings: BookingWithDetails[] │ │ │
|
|
│ │ │ • upcomingBookings: BookingWithDetails[] │ │ │
|
|
│ │ │ • loadingSlots: boolean │ │ │
|
|
│ │ │ • loadingBookings: boolean │ │ │
|
|
│ │ │ │ │ │
|
|
│ │ │ Actions: │ │ │
|
|
│ │ │ • fetchSlots(date) │ │ │
|
|
│ │ │ • createBooking(dto) │ │ │
|
|
│ │ │ • cancelBooking(bookingId) │ │ │
|
|
│ │ │ • fetchMyBookings() │ │ │
|
|
│ │ │ • fetchUpcomingBookings() │ │ │
|
|
│ │ │ │ │ │
|
|
│ │ │ stores/user.ts: │ │ │
|
|
│ │ │ State: │ │ │
|
|
│ │ │ • user: UserProfileResponse | null │ │ │
|
|
│ │ │ • memberships: MembershipWithCardType[] │ │ │
|
|
│ │ │ • token: string (from localStorage) │ │ │
|
|
│ │ │ │ │ │
|
|
│ │ │ Computed: │ │ │
|
|
│ │ │ • loggedIn: !!token │ │ │
|
|
│ │ │ • hasValidMembership: activeMemberships.length > 0 │ │ │
|
|
│ │ │ • activeMemberships: memberships filtered by ACTIVE │ │ │
|
|
│ │ │ │ │ │
|
|
│ │ │ Actions: │ │ │
|
|
│ │ │ • login() │ │ │
|
|
│ │ │ • fetchMemberships() │ │ │
|
|
│ │ │ • fetchProfile() │ │ │
|
|
│ │ │ • logout() │ │ │
|
|
│ │ └────────────────────────────────────────────────────────────────┘ │ │
|
|
│ │ ↓ │ │
|
|
│ │ ┌────────────────────────────────────────────────────────────────┐ │ │
|
|
│ │ │ Utils & Helpers │ │ │
|
|
│ │ │ │ │ │
|
|
│ │ │ utils/request.ts (HTTP Client): │ │ │
|
|
│ │ │ • request<T>(options): Promise<T> │ │ │
|
|
│ │ │ • get<T>(url, data?): Promise<T> │ │ │
|
|
│ │ │ • post<T>(url, data?): Promise<T> │ │ │
|
|
│ │ │ • put<T>(url, data?): Promise<T> │ │ │
|
|
│ │ │ │ │ │
|
|
│ │ │ utils/format.ts (Date Utilities): │ │ │
|
|
│ │ │ • formatDate(date): string │ │ │
|
|
│ │ │ • getWeekdayLabel(date): string │ │ │
|
|
│ │ │ • isToday(date): boolean │ │ │
|
|
│ │ │ • getDateRange(days): DateInfo[] │ │ │
|
|
│ │ └────────────────────────────────────────────────────────────────┘ │ │
|
|
│ └──────────────────────────────────────────────────────────────────────┘ │
|
|
│ ↕ │
|
|
│ HTTP Requests │
|
|
│ (Bearer Token in Header) │
|
|
│ ↓ │
|
|
│ ┌──────────────────────────────────────────────────────────────────────┐ │
|
|
│ │ BACKEND API │ │
|
|
│ │ (packages/server/src/time-slot, booking, membership modules) │ │
|
|
│ │ │ │
|
|
│ │ GET /api/time-slot/available?date=YYYY-MM-DD │ │
|
|
│ │ → TimeSlotWithBookingStatus[] │ │
|
|
│ │ │ │
|
|
│ │ POST /api/booking │ │
|
|
│ │ Body: { timeSlotId, membershipId } │ │
|
|
│ │ → BookingWithDetails │ │
|
|
│ │ │ │
|
|
│ │ PUT /api/booking/:bookingId/cancel │ │
|
|
│ │ → BookingWithDetails (status: CANCELLED) │ │
|
|
│ │ │ │
|
|
│ │ GET /api/membership/my │ │
|
|
│ │ → MembershipWithCardType[] │ │
|
|
│ └──────────────────────────────────────────────────────────────────────┘ │
|
|
│ ↕ │
|
|
│ ┌──────────────────────────────────────────────────────────────────────┐ │
|
|
│ │ DATABASE │ │
|
|
│ │ (TimeSlot, Booking, Membership, User tables) │ │
|
|
│ └──────────────────────────────────────────────────────────────────────┘ │
|
|
└─────────────────────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
---
|
|
|
|
## 📊 Data Flow Lifecycle
|
|
|
|
```
|
|
╔══════════════════════════════════════════════════════════════════════════╗
|
|
║ BOOKING PAGE LIFECYCLE ║
|
|
╚══════════════════════════════════════════════════════════════════════════╝
|
|
|
|
1. PAGE LOAD
|
|
┌─ onMounted()
|
|
│ ├─ IF loggedIn AND no memberships
|
|
│ │ └─ userStore.fetchMemberships()
|
|
│ │ GET /membership/my
|
|
│ │ → memberships array
|
|
│ │
|
|
│ └─ loadSlots(today)
|
|
│ → bookingStore.fetchSlots(date)
|
|
│ GET /time-slot/available?date=YYYY-MM-DD
|
|
│ → slots array
|
|
│ → Render SlotCard components
|
|
│
|
|
└─ READY ✓
|
|
|
|
2. USER SELECTS DATE
|
|
├─ onDateSelect(newDate)
|
|
├─ selectedDate.value = newDate
|
|
└─ loadSlots(newDate)
|
|
→ bookingStore.fetchSlots(newDate)
|
|
→ slots array (for new date)
|
|
→ SlotCard components re-render
|
|
|
|
3. USER SELECTS TIME PERIOD
|
|
├─ onPeriodChange(period)
|
|
├─ selectedPeriod.value = period
|
|
└─ filteredSlots computed updates automatically
|
|
→ Vue watches TIME_PERIODS[period]
|
|
→ Filters slots by startTime
|
|
→ SlotCard components re-render (subset)
|
|
|
|
4. USER PULLS TO REFRESH
|
|
├─ onRefresh()
|
|
├─ refreshing.value = true
|
|
├─ loadSlots(selectedDate.value)
|
|
│ → bookingStore.fetchSlots()
|
|
│ → slots array (refreshed)
|
|
└─ refreshing.value = false
|
|
|
|
5. USER TAPS "可预约" (Book)
|
|
├─ onBookTap(slot)
|
|
│
|
|
├─ CHECK: loggedIn?
|
|
│ ├─ NO → Show login modal
|
|
│ │ User clicks confirm
|
|
│ │ → userStore.login()
|
|
│ │ POST /auth/wxLogin
|
|
│ │ → token + user
|
|
│ │ → userStore.fetchMemberships()
|
|
│ │ GET /membership/my
|
|
│ │ → memberships array
|
|
│ │ → RETRY onBookTap(slot)
|
|
│ │
|
|
│ └─ YES → Continue
|
|
│
|
|
├─ CHECK: hasValidMembership?
|
|
│ ├─ NO → Show purchase modal
|
|
│ │ User clicks confirm
|
|
│ │ → uni.navigateTo('/pages/store/index')
|
|
│ │
|
|
│ └─ YES → Continue
|
|
│
|
|
├─ pendingSlot.value = slot
|
|
├─ showConfirmPopup.value = true
|
|
│
|
|
└─ POPUP SHOWN ✓
|
|
├─ selectedMembershipId auto-selected (first one)
|
|
├─ Watch on popup visibility + memberships
|
|
│ → Auto-select first membership when shown
|
|
│
|
|
└─ User sees:
|
|
• Slot date/time
|
|
• Membership card options
|
|
• Deduction message
|
|
|
|
6. USER CONFIRMS BOOKING
|
|
├─ onConfirmBooking({timeSlotId, membershipId})
|
|
├─ showConfirmPopup.value = false
|
|
├─ uni.showLoading('预约中...')
|
|
│
|
|
├─ bookingStore.createBooking(payload)
|
|
│ └─ POST /booking
|
|
│ Body: { timeSlotId, membershipId }
|
|
│ → BookingWithDetails
|
|
│
|
|
├─ uni.hideLoading()
|
|
├─ uni.showToast('预约成功!')
|
|
│
|
|
├─ loadSlots(selectedDate.value) // REFRESH
|
|
│ → bookingStore.fetchSlots()
|
|
│ GET /time-slot/available?date=
|
|
│ → slots array (UPDATED)
|
|
│ • slot.isBookedByMe = true
|
|
│ • slot.myBookingId = bookingId
|
|
│ • Button now shows "已预约"
|
|
│
|
|
└─ BOOKING COMPLETE ✓
|
|
|
|
7. USER TAPS "取消" (Cancel)
|
|
├─ onCancelTap(slot)
|
|
├─ Show confirmation modal
|
|
├─ User confirms
|
|
│
|
|
├─ uni.showLoading('取消中...')
|
|
│
|
|
├─ bookingStore.cancelBooking(slot.myBookingId)
|
|
│ └─ PUT /booking/:id/cancel
|
|
│ → BookingWithDetails (status: CANCELLED)
|
|
│
|
|
├─ uni.hideLoading()
|
|
├─ uni.showToast('已取消预约')
|
|
│
|
|
├─ loadSlots(selectedDate.value) // REFRESH
|
|
│ → bookingStore.fetchSlots()
|
|
│ GET /time-slot/available?date=
|
|
│ → slots array (UPDATED)
|
|
│ • slot.isBookedByMe = false
|
|
│ • slot.myBookingId = null
|
|
│ • Button now shows "可预约"
|
|
│
|
|
└─ CANCELLATION COMPLETE ✓
|
|
```
|
|
|
|
---
|
|
|
|
## 🔄 State Synchronization
|
|
|
|
```
|
|
Component ←→ Pinia Store ←→ API ←→ Database
|
|
|
|
┌──────────────────────────────────────────────────────────┐
|
|
│ Component (Vue Template) │
|
|
│ │
|
|
│ {{ bookingStore.slots }} ← Reactive binding │
|
|
│ {{ filteredSlots }} ← Computed from slots │
|
|
│ {{ userStore.hasValidMembership }} ← Computed from store │
|
|
│ │
|
|
│ @click="onBookTap(slot)" ← User action │
|
|
│ │
|
|
└──────────────────────────────────────────────────────────┘
|
|
↑ ↓
|
|
│ Read │ Mutate
|
|
│ ↓
|
|
┌──────────────────────────────────────────────────────────┐
|
|
│ Pinia Store State │
|
|
│ │
|
|
│ slots: TimeSlotWithBookingStatus[] │
|
|
│ ↓ Recomputed when: │
|
|
│ - fetchSlots() returns data │
|
|
│ - createBooking() succeeds │
|
|
│ - cancelBooking() succeeds │
|
|
│ │
|
|
│ memberships: MembershipWithCardType[] │
|
|
│ ↓ Set when: │
|
|
│ - fetchMemberships() returns data │
|
|
│ │
|
|
│ loadingSlots: boolean │
|
|
│ ↓ Set to: │
|
|
│ - true on fetchSlots() start │
|
|
│ - false on fetchSlots() end │
|
|
│ │
|
|
└──────────────────────────────────────────────────────────┘
|
|
↑ ↓
|
|
│ Response │ Request
|
|
│ ↓
|
|
┌──────────────────────────────────────────────────────────┐
|
|
│ API Layer (utils/request.ts) │
|
|
│ │
|
|
│ GET /time-slot/available?date=2026-04-05 │
|
|
│ ↓ Returns ApiResponse<TimeSlotWithBookingStatus[]> │
|
|
│ { success: true, data: [...], message: null } │
|
|
│ │
|
|
│ POST /booking │
|
|
│ ↓ Body: { timeSlotId, membershipId } │
|
|
│ ↓ Returns ApiResponse<BookingWithDetails> │
|
|
│ { success: true, data: {...}, message: null } │
|
|
│ │
|
|
│ PUT /booking/:id/cancel │
|
|
│ ↓ Returns ApiResponse<BookingWithDetails> │
|
|
│ { success: true, data: {...}, message: null } │
|
|
│ │
|
|
└──────────────────────────────────────────────────────────┘
|
|
↑ ↓
|
|
│ SELECT/UPDATE │ INSERT/UPDATE
|
|
│ ↓
|
|
┌──────────────────────────────────────────────────────────┐
|
|
│ Database │
|
|
│ │
|
|
│ TimeSlot Table │
|
|
│ id, date, startTime, endTime, capacity, │
|
|
│ bookedCount, status, source, templateId │
|
|
│ │
|
|
│ Booking Table │
|
|
│ id, userId, timeSlotId, membershipId, │
|
|
│ status (CONFIRMED/CANCELLED/...), bookedAt │
|
|
│ │
|
|
│ Membership Table │
|
|
│ id, userId, cardTypeId, status, remainingTimes, │
|
|
│ expireDate, createdAt, updatedAt │
|
|
│ │
|
|
└──────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
---
|
|
|
|
## 🎯 Component Communication
|
|
|
|
```
|
|
Root: pages/booking/index.vue
|
|
│
|
|
├─ PROPS DOWN ──→ DateSelector.vue
|
|
│ └─ modelValue: string (YYYY-MM-DD)
|
|
│
|
|
├─ PROPS DOWN ──→ TimePeriodFilter.vue
|
|
│ └─ modelValue: PeriodKey | null
|
|
│
|
|
├─ PROPS DOWN ──→ SlotCard.vue (v-for)
|
|
│ └─ slot: TimeSlotWithBookingStatus
|
|
│
|
|
├─ PROPS DOWN ──→ BookingConfirmPopup.vue
|
|
│ ├─ visible: boolean
|
|
│ ├─ slot: TimeSlotWithBookingStatus | null
|
|
│ └─ memberships: MembershipWithCardType[]
|
|
│
|
|
├─ EVENTS UP ←── DateSelector.vue
|
|
│ ├─ @select(date) → onDateSelect()
|
|
│ └─ @update:modelValue(date)
|
|
│
|
|
├─ EVENTS UP ←── TimePeriodFilter.vue
|
|
│ ├─ @change(period) → onPeriodChange()
|
|
│ └─ @update:modelValue(period)
|
|
│
|
|
├─ EVENTS UP ←── SlotCard.vue
|
|
│ ├─ @book(slot) → onBookTap()
|
|
│ └─ @cancel(slot) → onCancelTap()
|
|
│
|
|
└─ EVENTS UP ←── BookingConfirmPopup.vue
|
|
├─ @confirm({timeSlotId, membershipId}) → onConfirmBooking()
|
|
└─ @cancel → showConfirmPopup = false
|
|
```
|
|
|
|
---
|
|
|
|
## 🧬 Reactive Dependency Chain
|
|
|
|
```
|
|
LocalStorage (token)
|
|
↓
|
|
userStore.token
|
|
↓
|
|
userStore.loggedIn (computed)
|
|
↓
|
|
pages/booking → Check login status
|
|
↓
|
|
userStore.memberships
|
|
↓
|
|
userStore.activeMemberships (computed, filtered by ACTIVE)
|
|
↓
|
|
userStore.hasValidMembership (computed)
|
|
↓
|
|
pages/booking → Show/hide booking button & membership popup
|
|
↓
|
|
BookingConfirmPopup ← receives activeMemberships as props
|
|
↓
|
|
selectedMembershipId (auto-selected on popup show)
|
|
|
|
|
|
bookingStore.slots (array)
|
|
↓
|
|
pages/booking.selectedPeriod
|
|
↓
|
|
pages/booking.filteredSlots (computed, filtered by TIME_PERIODS)
|
|
↓
|
|
v-for → SlotCard components render
|
|
↓
|
|
Each SlotCard → capacityLabel (computed)
|
|
→ capacityClass (computed)
|
|
→ Button state determined
|
|
|
|
|
|
bookingStore.loadingSlots (boolean)
|
|
↓
|
|
pages/booking template
|
|
↓
|
|
v-if → Show skeleton | Show slots | Show empty state
|
|
```
|
|
|
|
---
|
|
|
|
## 📋 API Request/Response Chain
|
|
|
|
```
|
|
USER TAPS DATE
|
|
↓
|
|
pages/booking/onDateSelect()
|
|
↓
|
|
loadSlots(date)
|
|
↓
|
|
bookingStore.fetchSlots(date)
|
|
↓
|
|
get('/time-slot/available', { date })
|
|
↓
|
|
utils/request.get()
|
|
↓
|
|
uni.request({
|
|
url: 'http://localhost:3000/api/time-slot/available',
|
|
method: 'GET',
|
|
data: { date: '2026-04-05' },
|
|
header: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': 'Bearer <token>'
|
|
}
|
|
})
|
|
↓
|
|
BACKEND: GET /api/time-slot/available?date=2026-04-05
|
|
(Queries database for TimeSlot records matching date)
|
|
(Fetches current user's bookings for those slots)
|
|
(Enriches response with isBookedByMe, myBookingId)
|
|
↓
|
|
Response: {
|
|
"success": true,
|
|
"data": [
|
|
{
|
|
"id": "...",
|
|
"date": "2026-04-05",
|
|
"startTime": "09:00",
|
|
"endTime": "10:00",
|
|
"capacity": 1,
|
|
"bookedCount": 0,
|
|
"status": "OPEN",
|
|
"source": "MANUAL",
|
|
"templateId": null,
|
|
"isBookedByMe": false,
|
|
"myBookingId": null
|
|
},
|
|
...
|
|
],
|
|
"message": null
|
|
}
|
|
↓
|
|
request.ts success callback
|
|
├─ Check: statusCode < 400 ✓
|
|
├─ Check: body.success === true ✓
|
|
├─ Extract: body.data (TimeSlotWithBookingStatus[])
|
|
└─ Resolve promise with data
|
|
↓
|
|
bookingStore.fetchSlots() try block
|
|
├─ slots.value = data
|
|
└─ loadingSlots.value = false
|
|
↓
|
|
Component template reactivity
|
|
├─ Re-render with new slots
|
|
├─ Compute filteredSlots
|
|
└─ Render SlotCard components
|
|
```
|
|
|