feat(admin): implement full day-by-day schedule editor with live preview
## 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>
This commit is contained in:
552
BOOKING_ARCHITECTURE_DIAGRAM.md
Normal file
552
BOOKING_ARCHITECTURE_DIAGRAM.md
Normal file
@@ -0,0 +1,552 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user