## 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>
360 lines
13 KiB
Markdown
360 lines
13 KiB
Markdown
# Component & Data Flow Hierarchy
|
||
|
||
## 🏗️ Component Tree
|
||
|
||
```
|
||
pages/booking/index.vue (Main Page)
|
||
│
|
||
├── DateSelector.vue
|
||
│ └── Emits: @select (date string)
|
||
│ Props: v-model (current date)
|
||
│
|
||
├── TimePeriodFilter.vue
|
||
│ └── Emits: @change (period key)
|
||
│ Props: v-model (current period)
|
||
│
|
||
├── SlotCard.vue (Multiple, v-for)
|
||
│ ├── Props: slot (TimeSlotWithBookingStatus)
|
||
│ ├── Emits: @book (slot) / @cancel (slot)
|
||
│ └── Computed: capacityLabel, capacityClass
|
||
│
|
||
└── BookingConfirmPopup.vue (Modal)
|
||
├── Props: visible, slot, memberships
|
||
├── Emits: @confirm ({timeSlotId, membershipId})
|
||
├── Emits: @cancel
|
||
└── State: selectedMembershipId
|
||
```
|
||
|
||
---
|
||
|
||
## 🔄 State Management Flow
|
||
|
||
```
|
||
Pinia Store (stores/booking.ts)
|
||
├── State:
|
||
│ ├── slots: TimeSlotWithBookingStatus[]
|
||
│ ├── myBookings: BookingWithDetails[]
|
||
│ ├── upcomingBookings: BookingWithDetails[]
|
||
│ ├── loadingSlots: boolean
|
||
│ └── loadingBookings: boolean
|
||
│
|
||
└── Actions:
|
||
├── fetchSlots(date) → GET /time-slot/available?date=
|
||
├── createBooking({...}) → POST /booking
|
||
├── cancelBooking(bookingId) → PUT /booking/:id/cancel
|
||
├── fetchMyBookings(status?) → GET /booking/my
|
||
└── fetchUpcomingBookings() → GET /booking/my/upcoming
|
||
|
||
Pinia Store (stores/user.ts)
|
||
├── State:
|
||
│ ├── user: UserProfileResponse | null
|
||
│ ├── memberships: MembershipWithCardType[]
|
||
│ ├── token: string
|
||
│ └── stats: UserStatsResponse | null
|
||
│
|
||
├── Computed:
|
||
│ ├── loggedIn: boolean
|
||
│ ├── hasValidMembership: boolean
|
||
│ └── activeMemberships: MembershipWithCardType[]
|
||
│
|
||
└── Actions:
|
||
├── login() → WX login + token
|
||
├── fetchMemberships() → GET /membership/my
|
||
├── fetchProfile() → GET /user/profile
|
||
└── logout()
|
||
```
|
||
|
||
---
|
||
|
||
## 📡 API Calls Sequence
|
||
|
||
```
|
||
INITIAL LOAD
|
||
├─ POST /auth/wxLogin
|
||
│ └─ Returns: { token, user }
|
||
│
|
||
├─ GET /membership/my (if logged in)
|
||
│ └─ Returns: MembershipWithCardType[]
|
||
│
|
||
└─ GET /time-slot/available?date=TODAY
|
||
└─ Returns: TimeSlotWithBookingStatus[]
|
||
|
||
DATE CHANGE
|
||
└─ GET /time-slot/available?date=SELECTED_DATE
|
||
└─ Returns: TimeSlotWithBookingStatus[]
|
||
|
||
BOOKING CREATION
|
||
├─ POST /booking
|
||
│ ├─ Body: { timeSlotId, membershipId }
|
||
│ └─ Returns: BookingWithDetails
|
||
│
|
||
└─ GET /time-slot/available?date=SELECTED_DATE (refresh)
|
||
└─ Returns: Updated slots with isBookedByMe: true
|
||
|
||
BOOKING CANCELLATION
|
||
├─ PUT /booking/:bookingId/cancel
|
||
│ └─ Returns: Updated BookingWithDetails
|
||
│
|
||
└─ GET /time-slot/available?date=SELECTED_DATE (refresh)
|
||
└─ Returns: Updated slots with isBookedByMe: false
|
||
```
|
||
|
||
---
|
||
|
||
## 🎭 Slot Card State Machine
|
||
|
||
```
|
||
TimeSlotWithBookingStatus {
|
||
status: 'OPEN' | 'FULL' | 'CLOSED'
|
||
isBookedByMe: boolean
|
||
}
|
||
|
||
STATE COMBINATIONS:
|
||
|
||
┌─────────────────────────────────────┐
|
||
│ status: OPEN, isBookedByMe: false │
|
||
├─────────────────────────────────────┤
|
||
│ Button: "可预约" (Tan) │
|
||
│ Color: #c9a87c │
|
||
│ Action: onBookTap() → Popup │
|
||
└─────────────────────────────────────┘
|
||
|
||
┌─────────────────────────────────────┐
|
||
│ status: OPEN, isBookedByMe: true │
|
||
├─────────────────────────────────────┤
|
||
│ Badge: "已预约" │
|
||
│ Link: "取消" (Red underline) │
|
||
│ Indicator: Tan bar on left │
|
||
│ Action: onCancelTap() → Confirm │
|
||
└─────────────────────────────────────┘
|
||
|
||
┌─────────────────────────────────────┐
|
||
│ status: FULL │
|
||
├─────────────────────────────────────┤
|
||
│ Button: "已约满" (Gray) │
|
||
│ Color: #f0f0f0 │
|
||
│ Action: Disabled (no-op) │
|
||
└─────────────────────────────────────┘
|
||
|
||
┌─────────────────────────────────────┐
|
||
│ status: CLOSED │
|
||
├─────────────────────────────────────┤
|
||
│ Button: "已关闭" (Gray) │
|
||
│ Color: #f0f0f0 │
|
||
│ Action: Disabled (no-op) │
|
||
└─────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## 📊 Capacity Label Colors
|
||
|
||
```
|
||
Condition Label Background Text
|
||
─────────────────────────────────────────────────────────────────
|
||
status === CLOSED "已关闭" #f5f5f5 #999
|
||
status === FULL "0/1 人" #fef0f0 #ef4444
|
||
bookedCount >= 80% "0/1 人" #fff8ed #f59e0b
|
||
bookedCount < 80% "0/1 人" #f0faf3 #4caf50
|
||
```
|
||
|
||
---
|
||
|
||
## 🌐 Time Period Filters
|
||
|
||
```
|
||
Key Label Start End Range
|
||
──────────────────────────────────────────────────────
|
||
null (all) "全部" - - All times
|
||
'MORNING' "上午" 06:00 12:00 6am-12pm
|
||
'AFTERNOON' "下午" 12:00 18:00 12pm-6pm
|
||
'EVENING' "晚上" 18:00 22:00 6pm-10pm
|
||
|
||
Filtering Logic:
|
||
slot.startTime >= period.start && slot.startTime < period.end
|
||
```
|
||
|
||
---
|
||
|
||
## 📱 UI Layout Breakdown
|
||
|
||
```
|
||
┌─────────────────────────────────┐
|
||
│ 📱 Booking Page (750rpx) │
|
||
├─────────────────────────────────┤
|
||
│ │
|
||
│ ┌─────────────────────────────┐│
|
||
│ │ 🎫 STICKY HEADER (z-index:100)
|
||
│ │ ┌───────────────────────────┐│
|
||
│ │ │ DateSelector (horizontal) ││
|
||
│ │ │ 今天 5月 4月 3月... ││
|
||
│ │ └───────────────────────────┘│
|
||
│ │ ┌───────────────────────────┐│
|
||
│ │ │ TimePeriodFilter (tabs) ││
|
||
│ │ │ 全部 | 上午 | 下午 | 晚上││
|
||
│ │ └───────────────────────────┘│
|
||
│ └─────────────────────────────┘│
|
||
│ │
|
||
│ ┌─────────────────────────────┐│
|
||
│ │ 📜 SCROLL AREA ││
|
||
│ │ ││
|
||
│ │ OR [Loading skeleton] ×4 ││
|
||
│ │ OR [Empty state] ││
|
||
│ │ ││
|
||
│ │ [SlotCard 1] ┌──────────┐ ││
|
||
│ │ 09:00-10:00 │ 0/1 人 │ ││
|
||
│ │ │ [可预约] │ ││
|
||
│ │ ┌──────────┘ └─────────┘ ││
|
||
│ │ [SlotCard 2] ┌──────────┐ ││
|
||
│ │ 10:00-11:00 │ 1/1 人 │ ││
|
||
│ │ ✓已预约 [取消]└─────────┘ ││
|
||
│ │ [SlotCard 3] ... ││
|
||
│ │ ││
|
||
│ │ [Spacer 48rpx] ││
|
||
│ └─────────────────────────────┘│
|
||
│ │
|
||
│ ┌──────────────────────────────┐│
|
||
│ │ [BookingConfirmPopup] (Modal)││
|
||
│ │ ┌────────────────────────────┐│
|
||
│ │ │ ✕ 确认预约 ││
|
||
│ │ │ ││
|
||
│ │ │ 日期: 2026-04-05 ││
|
||
│ │ │ 时间: 09:00 - 10:00 ││
|
||
│ │ │ 剩余: 1 个名额 ││
|
||
│ │ │ ───────────────────── ││
|
||
│ │ │ 💳 私教课程 ││
|
||
│ │ │ 剩余 10 次 ✓ ││
|
||
│ │ │ 确认后扣除 1 次课时 ││
|
||
│ │ │ ││
|
||
│ │ │ [取消] [确认预约] ││
|
||
│ │ └────────────────────────────┘│
|
||
│ └──────────────────────────────┘│
|
||
└─────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## 🔐 Authentication Flow
|
||
|
||
```
|
||
PAGE LOAD
|
||
│
|
||
├─ Check: userStore.loggedIn?
|
||
│
|
||
├─ YES
|
||
│ ├─ Check: userStore.activeMemberships.length > 0?
|
||
│ │ ├─ NO: await fetchMemberships()
|
||
│ │ └─ YES: (already loaded)
|
||
│ │
|
||
│ └─ Load today's slots
|
||
│
|
||
└─ NO (not logged in)
|
||
└─ Page loads but booking disabled
|
||
(onBookTap shows login modal)
|
||
|
||
USER TAPS "可预约"
|
||
│
|
||
├─ Check: userStore.loggedIn?
|
||
│ ├─ NO: Show login modal
|
||
│ │ ├─ User confirms → wxLogin()
|
||
│ │ ├─ Retry booking flow
|
||
│ │ └─ Success: Load memberships, show popup
|
||
│ │
|
||
│ └─ YES: Continue
|
||
│
|
||
├─ Check: userStore.hasValidMembership?
|
||
│ ├─ NO: Show purchase modal
|
||
│ │ └─ User navigates to /pages/store/index
|
||
│ │
|
||
│ └─ YES: Continue
|
||
│
|
||
└─ Show BookingConfirmPopup
|
||
```
|
||
|
||
---
|
||
|
||
## ⚙️ Error Handling (Current)
|
||
|
||
```
|
||
fetchSlots() Error:
|
||
├─ console.error('Fetch slots failed:', err)
|
||
├─ slots.value = []
|
||
└─ UI shows: "当日暂无可约时段" (empty state)
|
||
❌ User can't distinguish network error from no slots
|
||
|
||
createBooking() Error:
|
||
├─ uni.showToast({ title: message, icon: 'none' })
|
||
└─ UI shows: Error toast (Good ✓)
|
||
|
||
cancelBooking() Error:
|
||
├─ uni.showToast({ title: message, icon: 'none' })
|
||
└─ UI shows: Error toast (Good ✓)
|
||
```
|
||
|
||
---
|
||
|
||
## 🧮 Computed Values & Reactivity
|
||
|
||
```
|
||
PAGE LEVEL:
|
||
scrollHeight = computed(() => {
|
||
// Recalc when window size changes
|
||
// = windowHeight - headerHeight - tabbarHeight
|
||
})
|
||
|
||
filteredSlots = computed(() => {
|
||
// Depends on: slots, selectedPeriod
|
||
// Recalc when either changes
|
||
// Filters by TIME_PERIODS[selectedPeriod].start/end
|
||
})
|
||
|
||
COMPONENT LEVEL:
|
||
SlotCard.capacityLabel = computed(() => {
|
||
// Depends on: slot.status, slot.bookedCount, slot.capacity
|
||
// Returns: "已关闭" | "X/Y 人"
|
||
})
|
||
|
||
SlotCard.capacityClass = computed(() => {
|
||
// Depends on: slot.status, slot.bookedCount, slot.capacity
|
||
// Returns: "cap-open" | "cap-almost" | "cap-full" | "cap-closed"
|
||
})
|
||
|
||
BookingConfirmPopup.selectedMembership = computed(() => {
|
||
// Depends on: selectedMembershipId, memberships
|
||
// Returns: Found membership or null
|
||
})
|
||
```
|
||
|
||
---
|
||
|
||
## 🎯 Key Data Transformations
|
||
|
||
```
|
||
Raw API Response
|
||
└─ TimeSlot {
|
||
date: "2026-04-05",
|
||
startTime: "09:00",
|
||
endTime: "10:00",
|
||
...
|
||
}
|
||
|
||
STORE (bookingStore.slots)
|
||
└─ TimeSlotWithBookingStatus extends TimeSlot {
|
||
isBookedByMe: boolean,
|
||
myBookingId: string | null
|
||
}
|
||
|
||
DISPLAY (SlotCard)
|
||
├─ capacityLabel: "0/1 人" | "已关闭"
|
||
├─ capacityClass: "cap-open" | "cap-almost" | "cap-full" | "cap-closed"
|
||
├─ Button state: "可预约" | "已预约" | "已约满" | "已关闭"
|
||
└─ Time display: "09:00 - 10:00" (slice first 5 chars)
|
||
|
||
BOOKING CREATION
|
||
├─ Selected Slot ID
|
||
├─ Selected Membership ID
|
||
└─ POST /booking
|
||
└─ Success: Slot updated with isBookedByMe: true
|
||
```
|
||
|