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>
This commit is contained in:
894
BOOKING_PAGE_ANALYSIS.md
Normal file
894
BOOKING_PAGE_ANALYSIS.md
Normal file
@@ -0,0 +1,894 @@
|
||||
# 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
|
||||
|
||||
Reference in New Issue
Block a user