## 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>
23 KiB
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 bookFULL- All slots bookedCLOSED- Time slot closed
Source Values:
MANUAL- Manually createdTEMPLATE- 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:
- Check login status → show login modal if needed
- Check hasValidMembership → show purchase modal if needed
- Set pendingSlot = slot
- 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 updateselect- 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 updatechange- 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 closesupdate: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:
- ✅ GET
/time-slot/available?date=...→ Returns slots - ✅ POST
/booking→ Create booking - ✅ PUT
/booking/:id/cancel→ Cancel booking - ✅ GET
/membership/my→ List memberships - ✅ Auth via Bearer token
From Frontend:
- ✅ LocalStorage for token persistence
- ✅ uni.showModal, uni.showToast for UI feedback
- ✅ uni.getSystemInfoSync() for responsive sizing
- ✅ 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