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

593 lines
15 KiB
Markdown

# Booking Page - Quick Reference & Code Snippets
## 🚀 Quick Start: Understanding the Flow
### Where Slots Come From
```typescript
// 1. Store calls API
packages/app/src/stores/booking.ts:17-27
async function fetchSlots(date: string) {
loadingSlots.value = true
try {
// GET /time-slot/available?date=2026-04-05
slots.value = await get<TimeSlotWithBookingStatus[]>(
'/time-slot/available',
{ date }
)
} catch (err) {
console.error('Fetch slots failed:', err)
slots.value = [] // ⚠️ Clears on error!
} finally {
loadingSlots.value = false
}
}
```
### Where Time Periods Are Defined
```typescript
// packages/shared/src/constants.ts:11-15
export const TIME_PERIODS = {
MORNING: { label: '上午', start: '06:00', end: '12:00' },
AFTERNOON: { label: '下午', start: '12:00', end: '18:00' },
EVENING: { label: '晚上', start: '18:00', end: '22:00' },
} as const
```
### Where Filtering Happens
```typescript
// pages/booking/index.vue:94-103
const filteredSlots = computed<TimeSlotWithBookingStatus[]>(() => {
const slots = bookingStore.slots as TimeSlotWithBookingStatus[]
if (!selectedPeriod.value) return [...slots]
const period = TIME_PERIODS[selectedPeriod.value]
return slots.filter((slot) => {
const t = slot.startTime // "09:00", "10:00", etc
return t >= period.start && t < period.end
})
})
```
### Slot Rendering
```vue
<!-- pages/booking/index.vue:34-42 -->
<view v-else class="slot-list">
<SlotCard
v-for="slot in filteredSlots"
:key="slot.id"
:slot="slot"
@book="onBookTap"
@cancel="onCancelTap"
/>
</view>
```
---
## 🔍 Finding Specific Things
### Q: Where do the time slot types come from?
**A:** `packages/shared/src/types/time-slot.ts`
```typescript
interface TimeSlotWithBookingStatus extends TimeSlot {
readonly isBookedByMe: boolean // true if user booked it
readonly myBookingId: string | null // needed for cancellation
}
interface TimeSlot {
readonly id: string // UUID
readonly date: string // "2026-04-05"
readonly startTime: string // "09:00"
readonly endTime: string // "10:00"
readonly capacity: number // 1 (for private lessons)
readonly bookedCount: number // 0 or 1
readonly status: TimeSlotStatus // OPEN|FULL|CLOSED
readonly source: TimeSlotSource // TEMPLATE|MANUAL
readonly templateId: string | null
readonly createdAt: string
readonly updatedAt: string
}
```
### Q: Where is the membership selection happening?
**A:** `components/BookingConfirmPopup.vue:136-147`
```typescript
const selectedMembershipId = ref<string>('')
watch(
[() => props.visible, () => props.memberships],
([visible, memberships]) => {
if (visible && memberships.length > 0) {
selectedMembershipId.value = memberships[0].id // Auto-select first
}
},
{ immediate: true },
)
```
### Q: Where are the button states determined?
**A:** `components/SlotCard.vue:15-45`
```vue
<!-- OPEN + not booked by me -->
<template v-if="slot.status === TimeSlotStatus.OPEN && !slot.isBookedByMe">
<view class="btn btn-book" @tap.stop="emit('book', slot)">
<text class="btn-text">可预约</text>
</view>
</template>
<!-- OPEN + booked by me -->
<template v-else-if="slot.status === TimeSlotStatus.OPEN && slot.isBookedByMe">
<view class="booked-row">
<view class="badge-booked">
<text class="badge-text">已预约</text>
</view>
<view class="btn-cancel" @tap.stop="emit('cancel', slot)">
<text class="btn-cancel-text">取消</text>
</view>
</view>
</template>
<!-- FULL or CLOSED -->
<template v-else>
<view class="btn btn-disabled">
<text class="btn-text">
{{ slot.status === TimeSlotStatus.FULL ? '已约满' : '已关闭' }}
</text>
</view>
</template>
```
### Q: Where is the API request actually made?
**A:** `utils/request.ts:22-59`
```typescript
export function request<T>(options: RequestOptions): Promise<T> {
return new Promise((resolve, reject) => {
const token = uni.getStorageSync('token') as string
uni.request({
url: `${BASE_URL}${options.url}`, // BASE_URL = http://localhost:3000/api
method: options.method || 'GET',
data: options.data,
header: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.header,
},
success: (res) => {
if (res.statusCode === 401) {
uni.removeStorageSync('token')
uni.showToast({ title: '请重新登录', icon: 'none' })
reject(new Error('Unauthorized'))
return
}
if (res.statusCode >= 400) {
const body = res.data as ApiResponse<unknown>
reject(new Error(body?.message || `请求失败 (${res.statusCode})`))
return
}
const body = res.data as ApiResponse<T>
if (body.success) {
resolve(body.data as T) // ← Extract data from ApiResponse
} else {
reject(new Error(body.message || '请求失败'))
}
},
fail: (err) => {
reject(new Error(err.errMsg || '网络请求失败'))
},
})
})
}
```
---
## 🐛 Debugging Tips
### Tip 1: Check what's in the store
```typescript
// In browser console while in booking page:
console.log('Slots:', JSON.stringify(uni.$u.pinia.state.value.booking.slots, null, 2))
console.log('Selected period:', uni.$u.pinia.state.value.booking.selectedPeriod)
```
### Tip 2: Log slot filtering
```typescript
// Add to pages/booking/index.vue filteredSlots computed:
const filteredSlots = computed<TimeSlotWithBookingStatus[]>(() => {
const slots = bookingStore.slots as TimeSlotWithBookingStatus[]
if (!selectedPeriod.value) {
console.log('No period filter, showing all slots:', slots.length)
return [...slots]
}
const period = TIME_PERIODS[selectedPeriod.value]
console.log(`Filtering by ${selectedPeriod.value}:`, period)
console.log('All slot times:', slots.map(s => s.startTime))
const filtered = slots.filter((slot) => {
const t = slot.startTime
const matches = t >= period.start && t < period.end
if (!matches) console.log(`${t} not in [${period.start}, ${period.end})`)
return matches
})
console.log('Filtered result:', filtered.length)
return filtered
})
```
### Tip 3: Verify API response
```typescript
// In stores/booking.ts fetchSlots():
async function fetchSlots(date: string) {
loadingSlots.value = true
try {
console.log('Fetching slots for date:', date)
slots.value = await get<TimeSlotWithBookingStatus[]>(
'/time-slot/available',
{ date }
)
console.log('Received slots:', slots.value)
console.log('Slot count:', slots.value.length)
if (slots.value.length > 0) {
console.log('First slot:', JSON.stringify(slots.value[0], null, 2))
}
} catch (err) {
console.error('Fetch slots failed:', err)
slots.value = []
} finally {
loadingSlots.value = false
}
}
```
### Tip 4: Check network requests
```typescript
// Open WeChat DevTools → Network tab
// Look for GET request to /time-slot/available
// Check:
// ✓ URL has ?date=YYYY-MM-DD
// ✓ Authorization header exists
// ✓ Response status 200
// ✓ Response body has "success": true
```
---
## ❌ Common Issues & Solutions
### Issue 1: Slots not loading
**Symptoms:**
- Page shows "当日暂无可约时段" (no slots)
- No error message
**Check list:**
```typescript
// 1. Is API endpoint correct?
// Check: /time-slot/available?date=2026-04-05
// Should return TimeSlotWithBookingStatus[]
// 2. Is date format correct?
// Page sends: formatDate(new Date()) → "2026-04-05"
// API expects: "YYYY-MM-DD"
console.log(formatDate(new Date())) // Should output: "2026-04-05"
// 3. Is authentication working?
console.log('Token:', uni.getStorageSync('token'))
// 4. Check for errors in console
// If fetchSlots fails, slots.value becomes []
```
**Solution:**
```typescript
// In bookingStore.fetchSlots(), add error state:
const error = ref<string | null>(null)
async function fetchSlots(date: string) {
loadingSlots.value = true
error.value = null // Clear previous error
try {
slots.value = await get<TimeSlotWithBookingStatus[]>(
'/time-slot/available',
{ date }
)
} catch (err) {
console.error('Fetch slots failed:', err)
error.value = err instanceof Error ? err.message : '加载失败'
slots.value = []
} finally {
loadingSlots.value = false
}
}
// Then in page template:
<view v-if="error" class="error-wrap">
<text>{{ error }}</text>
<view @tap="loadSlots(selectedDate)"></view>
</view>
```
### Issue 2: Time period filtering not working
**Symptoms:**
- Select "上午" (morning) but all slots still show
- Or vice versa
**Check:**
```typescript
// 1. Verify TIME_PERIODS constant
console.log('TIME_PERIODS:', TIME_PERIODS)
// 2. Check selectedPeriod value
console.log('Selected period:', selectedPeriod.value)
// 3. Verify slot.startTime format
// Should be "HH:MM" like "09:00", not "09:00:00"
bookingStore.slots.forEach(slot => {
console.log('Slot time:', slot.startTime, 'format ok?', /^\d{2}:\d{2}$/.test(slot.startTime))
})
// 4. Test filtering manually
const slot = bookingStore.slots[0]
const period = TIME_PERIODS.MORNING
console.log(`${slot.startTime} >= ${period.start}?`, slot.startTime >= period.start)
console.log(`${slot.startTime} < ${period.end}?`, slot.startTime < period.end)
```
**Solution:**
```typescript
// If time format is "09:00:00", slice it:
const filteredSlots = computed<TimeSlotWithBookingStatus[]>(() => {
const slots = bookingStore.slots as TimeSlotWithBookingStatus[]
if (!selectedPeriod.value) return [...slots]
const period = TIME_PERIODS[selectedPeriod.value]
return slots.filter((slot) => {
// Ensure HH:MM format
const t = slot.startTime.slice(0, 5) // "09:00:00" → "09:00"
return t >= period.start && t < period.end
})
})
```
### Issue 3: Booking button not responding
**Symptoms:**
- Click "可预约" but nothing happens
- No modal appears
**Check:**
```typescript
// 1. Is slot.status correct?
console.log('Slot status:', slot.status)
// Should be "OPEN" to show book button
// 2. Is isBookedByMe false?
console.log('Is booked by me?', slot.isBookedByMe)
// Should be false to show book button
// 3. Is onBookTap being called?
// Add to pages/booking/index.vue:
async function onBookTap(slot: TimeSlotWithBookingStatus) {
console.log('Book tapped for slot:', slot) // ← Should log
// Rest of code...
}
// 4. Is userStore.loggedIn true?
console.log('Logged in?', userStore.loggedIn)
```
### Issue 4: Membership not showing in popup
**Symptoms:**
- Booking popup appears but no membership card shown
- "暂无可用会员卡" displayed
**Check:**
```typescript
// 1. Are memberships loaded?
console.log('Memberships:', userStore.memberships)
// 2. Are any memberships ACTIVE?
console.log('Active memberships:', userStore.activeMemberships)
console.log('Has valid membership?', userStore.hasValidMembership)
// 3. Are memberships passed to popup?
// In pages/booking/index.vue:
<BookingConfirmPopup
:memberships="userStore.activeMemberships as MembershipWithCardType[]"
...
/>
console.log('Popup passed memberships:', userStore.activeMemberships)
```
**Solution:**
```typescript
// In onMounted:
onMounted(async () => {
if (userStore.loggedIn && userStore.activeMemberships.length === 0) {
console.log('Fetching memberships...')
try {
await userStore.fetchMemberships()
console.log('Memberships loaded:', userStore.activeMemberships)
} catch (err) {
console.error('Failed to fetch memberships:', err)
uni.showToast({ title: '加载会员卡失败', icon: 'none' })
}
}
await loadSlots(selectedDate.value)
})
```
---
## 📊 Capacity Display Logic
### How Capacity Color is Determined
```typescript
// components/SlotCard.vue:69-81
const capacityLabel = computed(() => {
const { bookedCount, capacity, status } = props.slot
if (status === TimeSlotStatus.CLOSED) return '已关闭'
return `${bookedCount}/${capacity}`
})
const capacityClass = computed(() => {
const { bookedCount, capacity, status } = props.slot
if (status === TimeSlotStatus.CLOSED) return 'cap-closed'
if (status === TimeSlotStatus.FULL) return 'cap-full'
if (bookedCount >= capacity * 0.8) return 'cap-almost'
return 'cap-open'
})
// Color mapping in styles:
// cap-open: #f0faf3 bg, #4caf50 text (green) - <80% booked
// cap-almost: #fff8ed bg, #f59e0b text (orange) - ≥80% booked
// cap-full: #fef0f0 bg, #ef4444 text (red) - status: FULL
// cap-closed: #f5f5f5 bg, #999 text (gray) - status: CLOSED
```
### Example Calculations
```typescript
// Slot 1: capacity=1, bookedCount=0, status=OPEN
// 0/1 人 in green badge (0% booked)
// Slot 2: capacity=1, bookedCount=1, status=OPEN
// 1/1 人 in red badge (100% booked ≥ 80%)
// Slot 3: capacity=5, bookedCount=4, status=OPEN
// 4/5 人 in orange badge (80% booked ≥ 80%)
// Slot 4: capacity=5, bookedCount=3, status=OPEN
// 3/5 人 in green badge (60% booked < 80%)
```
---
## 🔗 API Contract Summary
### GET /time-slot/available
**Request:**
```
GET /api/time-slot/available?date=2026-04-05
Authorization: Bearer <token>
```
**Response (200 OK):**
```json
{
"success": true,
"data": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"date": "2026-04-05",
"startTime": "09:00",
"endTime": "10:00",
"capacity": 1,
"bookedCount": 0,
"status": "OPEN",
"source": "MANUAL",
"templateId": null,
"createdAt": "2026-04-01T10:00:00Z",
"updatedAt": "2026-04-05T09:00:00Z",
"isBookedByMe": false,
"myBookingId": null
}
],
"message": null
}
```
**Error (400):**
```json
{
"success": false,
"data": null,
"message": "Invalid date format"
}
```
### POST /booking
**Request:**
```json
POST /api/booking
{
"timeSlotId": "550e8400-e29b-41d4-a716-446655440000",
"membershipId": "220e8400-e29b-41d4-a716-446655440111"
}
```
**Response (201):**
```json
{
"success": true,
"data": {
"id": "550e8400-e29b-41d4-a716-446655440222",
"userId": "user-123",
"timeSlotId": "550e8400-e29b-41d4-a716-446655440000",
"membershipId": "220e8400-e29b-41d4-a716-446655440111",
"status": "CONFIRMED",
"bookedAt": "2026-04-05T10:30:00Z",
"courseDate": "2026-04-05",
"courseTime": "09:00",
"instructorName": "instructor name",
"isCompleted": false
},
"message": null
}
```
### PUT /booking/:id/cancel
**Request:**
```
PUT /api/booking/550e8400-e29b-41d4-a716-446655440222/cancel
```
**Response (200):**
```json
{
"success": true,
"data": {
"id": "550e8400-e29b-41d4-a716-446655440222",
"status": "CANCELLED",
"cancelledAt": "2026-04-05T10:35:00Z"
},
"message": null
}
```
---
## 🎯 Next Steps for Debugging
1. **Verify API Endpoint**
- Open DevTools → Network
- Check `/time-slot/available?date=...` request
- Confirm response has `"success": true`
- Confirm data array is not empty
2. **Check Store State**
- Add console.logs to bookingStore.fetchSlots()
- Verify slots are set correctly
- Check loadingSlots toggle
3. **Verify Computed Properties**
- Log filteredSlots in component
- Check if filtering logic works
- Verify slot.startTime format
4. **Test User Interaction**
- Click date item → verify onDateSelect fires
- Click period tab → verify onPeriodChange fires
- Click book button → verify onBookTap fires
- Check modals appear
5. **Check Mobile-Specific Issues**
- Test in WeChat DevTools
- Check rpx calculations
- Verify touch events work