## 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>
895 lines
23 KiB
Markdown
895 lines
23 KiB
Markdown
# 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):**
|
||
```json
|
||
{
|
||
"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:**
|
||
```typescript
|
||
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:**
|
||
```typescript
|
||
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():**
|
||
```typescript
|
||
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
|
||
```typescript
|
||
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:
|
||
```typescript
|
||
await bookingStore.createBooking(payload)
|
||
// payload: { timeSlotId, membershipId }
|
||
await loadSlots(selectedDate.value) // Refresh
|
||
```
|
||
|
||
**onCancelTap(slot)** → Cancel booking:
|
||
```typescript
|
||
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:**
|
||
```typescript
|
||
slots: ref<readonly TimeSlotWithBookingStatus[]>([])
|
||
myBookings: ref<readonly BookingWithDetails[]>([])
|
||
upcomingBookings: ref<readonly BookingWithDetails[]>([])
|
||
loadingSlots: ref<boolean>(false)
|
||
loadingBookings: ref<boolean>(false)
|
||
```
|
||
|
||
**Actions:**
|
||
|
||
**fetchSlots(date: string)**
|
||
```typescript
|
||
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)**
|
||
```typescript
|
||
// dto: { timeSlotId: string; membershipId: string }
|
||
const result = await post<BookingWithDetails>('/booking', dto)
|
||
return result
|
||
```
|
||
|
||
**cancelBooking(bookingId: string)**
|
||
```typescript
|
||
const result = await put<BookingWithDetails>(`/booking/${bookingId}/cancel`)
|
||
return result
|
||
```
|
||
|
||
**fetchMyBookings(status?: string)**
|
||
```typescript
|
||
const params = status ? { status } : {}
|
||
myBookings.value = await get<BookingWithDetails[]>('/booking/my', params)
|
||
```
|
||
|
||
**fetchUpcomingBookings()**
|
||
```typescript
|
||
upcomingBookings.value = await get<BookingWithDetails[]>('/booking/my/upcoming')
|
||
```
|
||
|
||
---
|
||
|
||
### 3️⃣ **components/SlotCard.vue** (Individual Slot)
|
||
|
||
**Props:**
|
||
```typescript
|
||
interface Props {
|
||
slot: TimeSlotWithBookingStatus
|
||
}
|
||
```
|
||
|
||
**Emits:**
|
||
```typescript
|
||
book: [slot] // User wants to book
|
||
cancel: [slot] // User wants to cancel
|
||
```
|
||
|
||
**Template Sections:**
|
||
|
||
**1. Time & Capacity:**
|
||
```vue
|
||
<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**
|
||
```vue
|
||
<view class="btn btn-book">可预约</view>
|
||
<!-- Tan/brown button, emits: book -->
|
||
```
|
||
|
||
**State B: OPEN + booked by me**
|
||
```vue
|
||
<view class="badge-booked">已预约</view>
|
||
<view class="btn-cancel">取消</view>
|
||
<!-- Badge + cancel link, emits: cancel -->
|
||
```
|
||
|
||
**State C: FULL**
|
||
```vue
|
||
<view class="btn btn-disabled">已约满</view>
|
||
<!-- Gray disabled button -->
|
||
```
|
||
|
||
**State D: CLOSED**
|
||
```vue
|
||
<view class="btn btn-disabled">已关闭</view>
|
||
<!-- Gray disabled button -->
|
||
```
|
||
|
||
**3. Booked Indicator:**
|
||
```vue
|
||
<view v-if="slot.isBookedByMe" class="booked-bar" />
|
||
<!-- Tan bar on left side of card when booked by me -->
|
||
```
|
||
|
||
**Computed Properties:**
|
||
|
||
**capacityLabel:**
|
||
```typescript
|
||
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:**
|
||
```typescript
|
||
interface Props {
|
||
modelValue: string // YYYY-MM-DD
|
||
}
|
||
```
|
||
|
||
**Emits:**
|
||
- `update:modelValue` - v-model update
|
||
- `select` - Custom event on selection
|
||
|
||
**Data:**
|
||
```typescript
|
||
dateRange: computed(() => getDateRange(DATE_SELECTOR_DAYS))
|
||
// DATE_SELECTOR_DAYS = 7
|
||
// Returns array of { date, weekday, isToday }
|
||
```
|
||
|
||
**Template:**
|
||
```vue
|
||
<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:**
|
||
```typescript
|
||
type PeriodKey = keyof typeof TIME_PERIODS | null
|
||
|
||
interface Props {
|
||
modelValue: PeriodKey
|
||
}
|
||
```
|
||
|
||
**Emits:**
|
||
- `update:modelValue` - v-model update
|
||
- `change` - Custom event
|
||
|
||
**Constants:**
|
||
```typescript
|
||
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:**
|
||
```typescript
|
||
[
|
||
{ key: null, label: '全部' },
|
||
{ key: 'MORNING', label: '上午' },
|
||
{ key: 'AFTERNOON', label: '下午' },
|
||
{ key: 'EVENING', label: '晚上' },
|
||
]
|
||
```
|
||
|
||
**Template:**
|
||
```vue
|
||
<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:**
|
||
```typescript
|
||
visible: boolean
|
||
slot: TimeSlotWithBookingStatus | null
|
||
memberships: MembershipWithCardType[]
|
||
```
|
||
|
||
**Emits:**
|
||
- `confirm` - { timeSlotId, membershipId }
|
||
- `cancel` - Popup closes
|
||
- `update:visible` - Manual visibility control
|
||
|
||
**Template Sections:**
|
||
|
||
**1. Overlay Mask:**
|
||
```vue
|
||
<view v-if="visible" class="popup-mask" @tap="handleMaskTap">
|
||
<!-- Clicking mask closes popup -->
|
||
</view>
|
||
```
|
||
|
||
**2. Header:**
|
||
```vue
|
||
<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**
|
||
```vue
|
||
<view class="card-item selected">
|
||
💳
|
||
{{ membership.cardType.name }}
|
||
剩余 {{ remainingTimes }} 次
|
||
✓
|
||
</view>
|
||
```
|
||
(Auto-selected, pre-filled)
|
||
|
||
**Case B: Multiple memberships**
|
||
```vue
|
||
<view v-for="m in memberships" class="card-item"
|
||
:class="{ selected: selectedMembershipId === m.id }">
|
||
<!-- User taps to select -->
|
||
</view>
|
||
```
|
||
|
||
**5. Deduction Tip:**
|
||
```vue
|
||
<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:**
|
||
```typescript
|
||
watch([() => props.visible, () => props.memberships],
|
||
([visible, memberships]) => {
|
||
if (visible && memberships.length > 0) {
|
||
selectedMembershipId.value = memberships[0].id
|
||
}
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
```
|
||
|
||
**Confirm Handler:**
|
||
```typescript
|
||
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:**
|
||
```typescript
|
||
user: ref<UserProfileResponse | null>(null)
|
||
memberships: ref<readonly MembershipWithCardType[]>([])
|
||
token: ref<string>(uni.getStorageSync('token'))
|
||
```
|
||
|
||
**Key Computed:**
|
||
```typescript
|
||
loggedIn: computed(() => !!token.value)
|
||
activeMemberships: computed(() =>
|
||
memberships.value.filter(m => m.status === MembershipStatus.ACTIVE)
|
||
)
|
||
hasValidMembership: computed(() => activeMemberships.value.length > 0)
|
||
```
|
||
|
||
**Key Actions:**
|
||
```typescript
|
||
async function login()
|
||
async function fetchMemberships()
|
||
// GET /membership/my
|
||
async function logout()
|
||
```
|
||
|
||
---
|
||
|
||
### 8️⃣ **utils/request.ts** (API Client)
|
||
|
||
**Base URL Logic:**
|
||
```typescript
|
||
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:**
|
||
```typescript
|
||
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:**
|
||
```typescript
|
||
// 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)
|
||
|
||
```typescript
|
||
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)
|
||
```typescript
|
||
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
|
||
```typescript
|
||
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
|
||
```typescript
|
||
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
|
||
```typescript
|
||
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()`
|
||
```typescript
|
||
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()`
|
||
```typescript
|
||
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()`
|
||
```typescript
|
||
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`
|
||
```typescript
|
||
<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`
|
||
```typescript
|
||
确认后将从「{{ 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:**
|
||
```typescript
|
||
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
|
||
|