Files
mp-pilates/COMPONENT_HIERARCHY.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

13 KiB
Raw Blame History

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