Files
mp-pilates/BOOKING_ARCHITECTURE_DIAGRAM.md
richarjiang b6986ba30c 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>
2026-04-05 12:18:49 +08:00

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
```