## 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>
33 KiB
33 KiB
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