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:
richarjiang
2026-04-05 12:18:49 +08:00
parent 9c5dd4a911
commit b6986ba30c
29 changed files with 7810 additions and 19 deletions

359
COMPONENT_HIERARCHY.md Normal file
View File

@@ -0,0 +1,359 @@
# 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
```