## 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>
593 lines
15 KiB
Markdown
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
|
|
|