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

23 KiB
Raw Blame History

WeChat Mini-Program Booking Page Analysis

mp-pilates Project (Uni-app + Vue 3)


📋 Project Structure Overview

packages/app/src/
├── pages/
│   └── booking/
│       └── index.vue                 # 📍 Main booking page
├── components/
│   ├── DateSelector.vue              # Date picker (7 days)
│   ├── TimePeriodFilter.vue          # Morning/Afternoon/Evening filter
│   ├── SlotCard.vue                  # Individual time slot card
│   └── BookingConfirmPopup.vue       # Confirmation modal
├── stores/
│   ├── booking.ts                    # 📍 Booking state management
│   └── user.ts                       # User/membership state
└── utils/
    ├── request.ts                    # API request utilities
    └── format.ts                     # Date/time formatting utilities

🎯 API Flow

Endpoint: /api/time-slot/available?date=YYYY-MM-DD

Request:

  • Method: GET
  • Query params: date (YYYY-MM-DD format)
  • Authentication: Bearer token from localStorage

Response Format (from your example):

{
  "success": true,
  "data": [
    {
      "id": "string (UUID)",
      "date": "2026-04-05",
      "startTime": "09:00",
      "endTime": "10:00",
      "capacity": 1,
      "bookedCount": 0,
      "status": "OPEN",
      "source": "MANUAL",
      "templateId": null,
      "isBookedByMe": false,
      "myBookingId": null
    }
  ],
  "message": null
}

Status Values:

  • OPEN - Available to book
  • FULL - All slots booked
  • CLOSED - Time slot closed

Source Values:

  • MANUAL - Manually created
  • TEMPLATE - Generated from template

🔄 Complete Data Flow Diagram

User Opens Booking Page
         ↓
[onMounted] Lifecycle Hook
         ↓
1. Check if logged in + fetch memberships (if needed)
2. Load today's slots: bookingStore.fetchSlots(today)
         ↓
bookingStore.fetchSlots(date: string)
         ↓
request.get<TimeSlotWithBookingStatus[]>(
  '/time-slot/available',
  { date }
)
         ↓
Sets: bookingStore.slots = [TimeSlotWithBookingStatus[], ...]
         ↓
Vue renders via computed: filteredSlots
         ↓
User selects date OR filters by time period
         ↓
Updates: selectedDate.value or selectedPeriod.value
         ↓
Computed filteredSlots re-calculates
         ↓
Renders SlotCard components
         ↓
User taps "可预约" (Book Button)
         ↓
[onBookTap(slot)]
  - Check login (if not → show login modal)
  - Check valid membership (if not → show purchase modal)
  - Show BookingConfirmPopup
         ↓
User selects membership + confirms
         ↓
[onConfirmBooking(payload)]
  - bookingStore.createBooking({timeSlotId, membershipId})
  - POST /api/booking
  - Refresh slots: loadSlots(selectedDate.value)
         ↓
Success/Error Toast

📄 File-by-File Analysis

1 pages/booking/index.vue (Main Component)

Template Structure:

.booking-page
├── .sticky-header (z-index: 100)
│   ├── DateSelector (v-model="selectedDate")
│   └── TimePeriodFilter (v-model="selectedPeriod")
├── scroll-view.slot-scroll
│   ├── Loading skeleton (4 cards) - when loadingSlots
│   ├── Empty state - when no slots
│   └── SlotCard list - main content
│       └── SlotCard (v-for="slot in filteredSlots")
└── BookingConfirmPopup (conditional)

Script Setup - State Variables:

selectedDate: ref<string>           // YYYY-MM-DD format
selectedPeriod: ref<PeriodKey>     // 'MORNING'|'AFTERNOON'|'EVENING'|null
showConfirmPopup: ref<boolean>     // Modal visibility
pendingSlot: ref<Slot | null>      // Slot being booked
refreshing: ref<boolean>           // Pull-to-refresh state

Computed Properties:

scrollHeight: computed(() => {
  // Calculates scroll area height:
  // windowHeight - headerHeight (220rpx) - tabbarHeight (100rpx)
  // Converts rpx to pixels dynamically
})

filteredSlots: computed(() => {
  // If no period selected: return all slots
  // If period selected: filter by TIME_PERIODS[selectedPeriod].start/.end
  // Compares slot.startTime with period.start and period.end
})

Key Lifecycle - onMounted():

1. If logged in but no memberships fetched yet:
    await userStore.fetchMemberships()
2. Load today's slots:
    await loadSlots(formatDate(new Date()))

Event Handlers:

onDateSelect(date: string) → Changes selectedDate, calls loadSlots()

onPeriodChange(period) → Updates selectedPeriod (filtering is automatic via computed)

onRefresh() → Pull-to-refresh handler

refreshing.value = true
await loadSlots(selectedDate.value)
refreshing.value = false

onBookTap(slot) → Book button clicked:

  1. Check login status → show login modal if needed
  2. Check hasValidMembership → show purchase modal if needed
  3. Set pendingSlot = slot
  4. Show BookingConfirmPopup

onConfirmBooking(payload) → User confirms booking:

await bookingStore.createBooking(payload)
// payload: { timeSlotId, membershipId }
await loadSlots(selectedDate.value)  // Refresh

onCancelTap(slot) → Cancel booking:

if (!slot.myBookingId) return
// Show confirmation modal
await bookingStore.cancelBooking(slot.myBookingId)
await loadSlots(selectedDate.value)  // Refresh

Styles:

  • Page background: #f5f3f0 (light beige)
  • Sticky header with box-shadow
  • Loading skeleton with shimmer animation
  • Empty state centered with image

2 stores/booking.ts (State Management)

State:

slots: ref<readonly TimeSlotWithBookingStatus[]>([])
myBookings: ref<readonly BookingWithDetails[]>([])
upcomingBookings: ref<readonly BookingWithDetails[]>([])
loadingSlots: ref<boolean>(false)
loadingBookings: ref<boolean>(false)

Actions:

fetchSlots(date: string)

async function fetchSlots(date: string) {
  loadingSlots.value = true
  try {
    slots.value = await get<TimeSlotWithBookingStatus[]>(
      '/time-slot/available',
      { date }  // ← date as query param
    )
  } catch (err) {
    console.error('Fetch slots failed:', err)
    slots.value = []
  } finally {
    loadingSlots.value = false
  }
}

⚠️ CRITICAL: If request fails, slots.value becomes empty []

createBooking(dto: CreateBookingDto)

// dto: { timeSlotId: string; membershipId: string }
const result = await post<BookingWithDetails>('/booking', dto)
return result

cancelBooking(bookingId: string)

const result = await put<BookingWithDetails>(`/booking/${bookingId}/cancel`)
return result

fetchMyBookings(status?: string)

const params = status ? { status } : {}
myBookings.value = await get<BookingWithDetails[]>('/booking/my', params)

fetchUpcomingBookings()

upcomingBookings.value = await get<BookingWithDetails[]>('/booking/my/upcoming')

3 components/SlotCard.vue (Individual Slot)

Props:

interface Props {
  slot: TimeSlotWithBookingStatus
}

Emits:

book: [slot]    // User wants to book
cancel: [slot]  // User wants to cancel

Template Sections:

1. Time & Capacity:

<text>{{ slot.startTime.slice(0, 5) }} - {{ slot.endTime.slice(0, 5) }}</text>
<!-- e.g., "09:00 - 10:00" -->

<view class="slot-capacity" :class="capacityClass">
  {{ capacityLabel }}
</view>

2. Action Buttons (4 States):

State A: OPEN + not booked by me

<view class="btn btn-book">可预约</view>
<!-- Tan/brown button, emits: book -->

State B: OPEN + booked by me

<view class="badge-booked">已预约</view>
<view class="btn-cancel">取消</view>
<!-- Badge + cancel link, emits: cancel -->

State C: FULL

<view class="btn btn-disabled">已约满</view>
<!-- Gray disabled button -->

State D: CLOSED

<view class="btn btn-disabled">已关闭</view>
<!-- Gray disabled button -->

3. Booked Indicator:

<view v-if="slot.isBookedByMe" class="booked-bar" />
<!-- Tan bar on left side of card when booked by me -->

Computed Properties:

capacityLabel:

if (status === CLOSED) return '已关闭'
return `${bookedCount}/${capacity} 人`  // e.g., "0/1 人"

capacityClass: Determines background color

CLOSED    → cap-closed (gray)
FULL      → cap-full (red bg, red text)
≥80%      → cap-almost (orange bg, orange text)
<80%      → cap-open (green bg, green text)

Styles:

  • Card: white background, 20rpx border-radius, shadow
  • Time text: 36rpx, bold, dark
  • Capacity badge: 22rpx, inline-flex, colored backgrounds
  • Buttons: rounded pills (68rpx height, 34rpx border-radius)
  • Cancel text: underlined, red (#ef4444)
  • Booked bar: 6rpx tan bar on left edge

4 components/DateSelector.vue (Date Picker)

Props:

interface Props {
  modelValue: string  // YYYY-MM-DD
}

Emits:

  • update:modelValue - v-model update
  • select - Custom event on selection

Data:

dateRange: computed(() => getDateRange(DATE_SELECTOR_DAYS))
// DATE_SELECTOR_DAYS = 7
// Returns array of { date, weekday, isToday }

Template:

<scroll-view scroll-x>
  <view class="track">
    <view v-for="item in dateRange" class="date-item"
          :class="{ active: item.date === modelValue, today: item.isToday }">
      <text class="weekday">{{ item.isToday ? '今天' : item.weekday }}</text>
      <text class="day">{{ getDayNumber(item.date) }}</text>
      <text class="month">{{ getMonthNumber(item.date) }}</text>
    </view>
  </view>
</scroll-view>

Date Display Format:

  • Weekday: "周一", "周二", or "今天"
  • Day: Large bold number (e.g., "5")
  • Month: Small number (e.g., "4月")

Styles:

  • Active state: tan background (#c9a87c), white text
  • Today highlight: tan-colored weekday text (even if not active)
  • Horizontal scroll, no scrollbar

5 components/TimePeriodFilter.vue (Period Filter)

Props:

type PeriodKey = keyof typeof TIME_PERIODS | null

interface Props {
  modelValue: PeriodKey
}

Emits:

  • update:modelValue - v-model update
  • change - Custom event

Constants:

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' },
}

Tabs Generated:

[
  { key: null, label: '全部' },
  { key: 'MORNING', label: '上午' },
  { key: 'AFTERNOON', label: '下午' },
  { key: 'EVENING', label: '晚上' },
]

Template:

<view v-for="tab in tabs" :class="{ active: modelValue === tab.key }">
  {{ tab.label }}
</view>

Active State:

  • Text color: tan (#c9a87c), weight: 600
  • Bottom border: 4rpx tan underline (CSS ::after)

6 components/BookingConfirmPopup.vue (Confirmation Modal)

Props:

visible: boolean
slot: TimeSlotWithBookingStatus | null
memberships: MembershipWithCardType[]

Emits:

  • confirm - { timeSlotId, membershipId }
  • cancel - Popup closes
  • update:visible - Manual visibility control

Template Sections:

1. Overlay Mask:

<view v-if="visible" class="popup-mask" @tap="handleMaskTap">
  <!-- Clicking mask closes popup -->
</view>

2. Header:

<text class="popup-title">确认预约</text>
<view class="close-btn"></view>

3. Info Section (read-only display):

日期: 2026-04-05
时间: 09:00 - 10:00
剩余: 1 个名额

4. Membership Card Selection:

Case A: 1 membership

<view class="card-item selected">
  💳
  {{ membership.cardType.name }}
  剩余 {{ remainingTimes }} 
  
</view>

(Auto-selected, pre-filled)

Case B: Multiple memberships

<view v-for="m in memberships" class="card-item"
      :class="{ selected: selectedMembershipId === m.id }">
  <!-- User taps to select -->
</view>

5. Deduction Tip:

<view v-if="selectedMembership" class="deduction-tip">
  确认后将从{{ selectedMembership.cardType.name }}扣除 1 次课时
</view>

6. Action Buttons:

[取消]                  [确认预约]
(Outline)              (Tan solid)
(Disabled if no membership selected)

Auto-selection Logic:

watch([() => props.visible, () => props.memberships], 
  ([visible, memberships]) => {
    if (visible && memberships.length > 0) {
      selectedMembershipId.value = memberships[0].id
    }
  },
  { immediate: true }
)

Confirm Handler:

function handleConfirm() {
  emit('confirm', {
    timeSlotId: props.slot.id,
    membershipId: selectedMembershipId.value,
  })
}

Styles:

  • Modal: Fixed positioning, rgba(0,0,0,0.45) dark overlay
  • Panel: White background, rounded top corners, 32rpx padding
  • Card items: 24rpx padding, border, transition on select
  • Buttons: 88rpx height, rounded pills (44rpx)
  • Cancel: Outline style, gray text
  • Confirm: Solid tan background, white text

7 stores/user.ts (User State)

Key State:

user: ref<UserProfileResponse | null>(null)
memberships: ref<readonly MembershipWithCardType[]>([])
token: ref<string>(uni.getStorageSync('token'))

Key Computed:

loggedIn: computed(() => !!token.value)
activeMemberships: computed(() =>
  memberships.value.filter(m => m.status === MembershipStatus.ACTIVE)
)
hasValidMembership: computed(() => activeMemberships.value.length > 0)

Key Actions:

async function login()
async function fetchMemberships()
  // GET /membership/my
async function logout()

8 utils/request.ts (API Client)

Base URL Logic:

const BASE_URL = (() => {
  const { miniProgram } = uni.getAccountInfoSync()
  if (miniProgram.envVersion !== 'develop') {
    return 'https://focus.richarjiang.com/api'
  }
  return 'http://localhost:3000/api'
})()

Main request() function:

function request<T>(options: RequestOptions): Promise<T> {
  // 1. Get token from localStorage
  const token = uni.getStorageSync('token')
  
  // 2. Call uni.request with:
  //    - Authorization header (Bearer token)
  //    - Content-Type: application/json
  
  // 3. Response handling:
  //    - 401 → Clear token, show "please login", reject
  //    - ≥400 → Extract error from response.message, reject
  //    - <400 & success: true → Resolve with data
  //    - <400 & success: false → Reject with message
  
  // 4. Network fail → Reject with errMsg
}

export function get<T>(url, data?): Promise<T>
export function post<T>(url, data?): Promise<T>
export function put<T>(url, data?): Promise<T>

⚠️ GET Request Issue:

// In get(), data becomes the request body
// But uni.request with GET should NOT have a body
// Query params should be in the URL string
// This might cause issues on some platforms!

9 utils/format.ts (Date Utilities)

formatDate(date): string
  // Returns YYYY-MM-DD

getWeekdayLabel(date): string
  // Returns "周一", "周二", ..., "周日"

isToday(date): boolean
  // Compares year/month/day

getDateRange(days: number): ReadonlyArray
  // Returns array of:
  // {
  //   date: YYYY-MM-DD,
  //   weekday: "周一" | "今天" (if i===0),
  //   isToday: boolean
  // }
  // Uses i * 86400000ms for date increment

🔍 Data Types Overview

TimeSlotWithBookingStatus (Extended from TimeSlot)

interface TimeSlotWithBookingStatus extends TimeSlot {
  readonly isBookedByMe: boolean      // Has user already booked?
  readonly myBookingId: string | null // ID needed to cancel
}

interface TimeSlot {
  readonly id: string                 // UUID
  readonly date: string               // YYYY-MM-DD
  readonly startTime: string          // HH:MM
  readonly endTime: string            // HH:MM
  readonly capacity: number           // Max people
  readonly bookedCount: number        // Already booked
  readonly status: TimeSlotStatus     // OPEN|FULL|CLOSED
  readonly source: TimeSlotSource     // TEMPLATE|MANUAL
  readonly templateId: string | null
}

MembershipWithCardType

interface MembershipWithCardType {
  readonly id: string
  readonly cardType: CardType
  readonly status: MembershipStatus   // ACTIVE|EXPIRED|USED_UP
  readonly remainingTimes: number | null
  readonly expireDate: string         // YYYY-MM-DD
}

CreateBookingDto

interface CreateBookingDto {
  readonly timeSlotId: string
  readonly membershipId: string
}

🎨 Color Scheme

Element Color Hex Usage
Primary (Accent) Tan/Brown #c9a87c Buttons, active tabs, highlights
Background Light Beige #f5f3f0 Page background
Text Primary Dark Gray #1a1a1a Main headings
Text Secondary Medium Gray #666/#999 Labels, descriptions
Text Tertiary Light Gray #bbb Disabled, hints
Success Green #4caf50 Open slots (capacity label)
Warning Orange #f59e0b Almost full (capacity label)
Error Red #ef4444 Full/closed, cancel button
Borders Very Light Gray #f0f0f0/#f0ece8 Dividers, borders

⚠️ Potential Issues & Problems

1. GET Request Body Issue

File: utils/request.ts in get() function

export function get<T>(url: string, data?: Record<string, unknown>): Promise<T> {
  return request<T>({ url, method: 'GET', data })  // ← data as body!
}

Problem: GET requests shouldn't have a body. Query params should be in the URL. Impact: /time-slot/available?date=2026-04-05 might not work on all platforms.

2. Empty Slots Array on Error

File: stores/booking.ts, fetchSlots()

catch (err) {
  console.error('Fetch slots failed:', err)
  slots.value = []  // ← Clears state on error!
}

Problem: Network error → page shows "empty state" instead of error message. Impact: Users can't tell if there's an error or truly no slots available.

3. No Error Handling in Main Page

File: pages/booking/index.vue, loadSlots()

async function loadSlots(date: string) {
  await bookingStore.fetchSlots(date)
  // ← No error handling, no user feedback
}

Problem: If fetchSlots() fails, user sees empty page with no explanation.

4. Manual Date Calculation

File: utils/format.ts, getDateRange()

const d = new Date(now.getTime() + i * 86400000)

Problem: Doesn't account for DST transitions. Using Date.setDate() would be safer.

5. No Loading State for Slots

File: pages/booking/index.vue

<view v-if="bookingStore.loadingSlots && !refreshing" class="loading-wrap">

Problem: Skeleton appears only on initial load, not when changing dates or refreshing. Impact: Date changes appear instant (good UX but confusing if slow network).

6. Hardcoded Membership Message

File: components/BookingConfirmPopup.vue

确认后将从「{{ selectedMembership.cardType.name }}」扣除 1 次课时
// ← Always says "1 次" even if card might deduct different amounts

Problem: Doesn't show actual deduction amount if dynamic.


📊 Event Flow Sequence

1. PAGE LOAD (onMounted)
   ├─ Check: userStore.loggedIn?
   ├─ If yes & no memberships: fetchMemberships()
   └─ loadSlots(today)
        └─ GET /time-slot/available?date=today
        └─ bookingStore.slots = [...]
        └─ render SlotCard components

2. USER TAPS DATE
   ├─ selectedDate.value = newDate
   └─ onDateSelect(newDate)
        └─ loadSlots(newDate)
            └─ fetchSlots()

3. USER FILTERS PERIOD
   ├─ selectedPeriod.value = MORNING|AFTERNOON|EVENING|null
   └─ filteredSlots computed updates
        └─ SlotCards re-render (no new API call)

4. USER PULLS TO REFRESH
   ├─ onRefresh()
   └─ loadSlots(selectedDate.value)

5. USER TAPS "可预约" BUTTON
   ├─ onBookTap(slot)
   ├─ Check login (if not → login modal)
   ├─ Check membership (if not → purchase modal)
   └─ Show BookingConfirmPopup
        └─ Pre-select first membership

6. USER CONFIRMS BOOKING
   ├─ onConfirmBooking({timeSlotId, membershipId})
   ├─ POST /booking
   │   └─ bookingStore.createBooking()
   ├─ Show success toast
   └─ loadSlots(selectedDate.value)  // Refresh
        └─ Updated slot.isBookedByMe = true

7. USER TAPS "取消" BUTTON
   ├─ onCancelTap(slot)
   ├─ Confirm modal
   ├─ PUT /booking/:id/cancel
   │   └─ bookingStore.cancelBooking()
   ├─ Show success toast
   └─ loadSlots(selectedDate.value)  // Refresh
        └─ Updated slot.isBookedByMe = false

🧪 Testing Scenarios

Happy Path

  • Load page → today's slots display
  • Tap date → slots for that date display
  • Filter by period → slots filtered correctly
  • Tap "可预约" → popup shows with correct time/date
  • Select membership → deduction message updates
  • Confirm → booking created, slot shows "已预约"
  • Pull to refresh → slots reload
  • Tap "取消" → booking cancelled, slot back to "可预约"

⚠️ Edge Cases

  • No slots for date → empty state appears
  • User not logged in → login modal shows
  • No valid membership → purchase modal shows
  • Network error → ??? (currently shows empty)
  • Slot changes to FULL → button becomes disabled
  • Slot changes to CLOSED → button becomes disabled

🔧 Integration Points

From Backend:

  1. GET /time-slot/available?date=... → Returns slots
  2. POST /booking → Create booking
  3. PUT /booking/:id/cancel → Cancel booking
  4. GET /membership/my → List memberships
  5. Auth via Bearer token

From Frontend:

  1. LocalStorage for token persistence
  2. uni.showModal, uni.showToast for UI feedback
  3. uni.getSystemInfoSync() for responsive sizing
  4. uni.navigateTo() for page navigation

📱 Responsive Layout

Design Breakpoint:

  • Base: 750rpx (WeChat standard width unit)
  • Window height: dynamic via uni.getSystemInfoSync().windowHeight

Scroll Area Height Calculation:

scrollHeight = windowHeight - headerHeight(220rpx) - tabbarHeight(100rpx)
            = windowHeight - (220 * (windowWidth / 750)) - (100 * (windowWidth / 750))

Sticky Header:

  • Position: sticky (CSS)
  • Top: 0
  • Z-index: 100
  • Contains: DateSelector + TimePeriodFilter

🎯 Summary

The booking system is well-architected with:

  • Clear separation of concerns (component, store, utils)
  • Proper type safety with TypeScript
  • Responsive date/time selection
  • Membership-based booking validation
  • Optimistic loading states
  • Accessible UI patterns

But needs:

  • ⚠️ Better error handling
  • ⚠️ Fix GET request implementation
  • ⚠️ Loading state during date/period changes
  • ⚠️ Network error user feedback