perf: 优化 UI

This commit is contained in:
richarjiang
2026-04-06 11:15:10 +08:00
parent 3a9982209f
commit 66d47ec162
24 changed files with 822 additions and 7129 deletions

View File

@@ -1,803 +0,0 @@
# WeChat Mini-Program Admin Scheduling/排课设置 - Complete Exploration Report
**Date**: 2026-04-05
**Project**: mp-pilates (WeChat mini-program for pilates studio bookings)
---
## 📋 Executive Summary
This is a **Pilates studio booking management system** with a comprehensive admin scheduling UI. The "排课设置" (Schedule Setup) feature allows admins to:
1. Define recurring weekly class templates (时间模板)
2. Manually add time slots for specific dates
3. Close slots (临时调整 → 关闭时段)
4. Batch generate slots from templates
The architecture uses:
- **Frontend**: Vue 3 + TypeScript (WeChat mini-program with Taro/UNI framework)
- **Backend**: NestJS + Prisma ORM
- **State Management**: Pinia (Vue state management)
- **Database**: Likely PostgreSQL/MySQL with Prisma
---
## 🗂️ File Structure
### Frontend Admin Pages
```
packages/app/src/pages/admin/
├── index.vue # Admin dashboard with nav grid
├── week-template.vue # 📅 Scheduling/排课设置 - Main feature
├── slot-adjust.vue # 🔧 Temporary adjustments (3 tabs)
├── members.vue # 👥 Member management
├── orders.vue # 📋 Order management
├── card-types.vue # 💳 Card type management
└── studio.vue # 🏢 Studio settings
```
### Stores
```
packages/app/src/stores/
└── admin.ts # Pinia store with all admin API calls
```
### Backend API
```
packages/server/src/
├── time-slot/
│ ├── time-slot.controller.ts # Admin & member endpoints for slots
│ ├── time-slot.service.ts # Business logic for slots
│ ├── slot-generator.service.ts # Template-based slot generation
│ └── dto/
│ ├── week-template.dto.ts # Input validation
│ ├── create-manual-slot.dto.ts
│ └── query-slots.dto.ts
├── studio/
│ └── studio.controller.ts # Studio config (admin endpoints)
└── scheduler/ # Cron scheduler for auto-generation
```
### Shared Types
```
packages/shared/src/types/
├── week-template.ts # WeekTemplate interface
├── time-slot.ts # TimeSlot interface
└── constants.ts # WEEKDAY_LABELS, SLOT_GENERATION_DAYS
```
---
## 🔑 Key Components
### 1. **Admin Dashboard (index.vue)**
**File**: `packages/app/src/pages/admin/index.vue`
**Features**:
- Display stats: today's bookings, total orders, total bookings
- Navigation grid to 6 admin modules:
- 📅 **排课设置**`/pages/admin/week-template`
- 🔧 **临时调整**`/pages/admin/slot-adjust`
- 👥 **会员管理**`/pages/admin/members`
- 📋 **订单管理**`/pages/admin/orders`
- 💳 **卡种管理**`/pages/admin/card-types`
- 🏢 **工作室设置**`/pages/admin/studio`
**Key Functions**:
```typescript
- navigate(path): Navigates to admin pages
- loadStats(): Fetches dashboard statistics via adminStore.fetchDashboardStats()
```
**State**:
```typescript
const stats = ref<AdminStats>({ todayBookings: 0, totalOrders: 0, totalBookings: 0 })
const statsLoading = ref(false)
```
---
### 2. **Week Template Management (week-template.vue)** ✨ MAIN SCHEDULING UI
**File**: `packages/app/src/pages/admin/week-template.vue`
**Route**: `/pages/admin/week-template`
#### Purpose
Manage recurring weekly schedule templates. These are used to **auto-generate** time slots for future weeks.
#### Data Structure
```typescript
interface WeekTemplate {
readonly id: string
readonly dayOfWeek: number // 1=Mon, 2=Tue, ..., 7=Sun (ISO format)
readonly startTime: string // HH:MM format
readonly endTime: string // HH:MM format
readonly capacity: number // Max bookings per slot
readonly isActive: boolean // Enable/disable template
readonly createdAt: string
readonly updatedAt: string
}
```
#### UI Sections
1. **Toolbar**
- Display template count: "共 N 条模板"
- "+ 新增时段" (Add new slot) button
2. **Template List** (Grouped by weekday)
- Days are sorted (Monday → Sunday)
- Each day shows count: "3 个时段"
- Each template row displays:
- Time range: "09:00 10:00"
- Capacity: "10 人"
- Actions:
- Toggle button: "启用"/"停用" (Enable/Disable)
- "编辑" (Edit)
- "删除" (Delete)
- Inactive templates are grayed out (opacity: 0.5)
3. **Modal for Add/Edit**
- Star date picker (1-7 for weekday)
- Start time picker
- End time picker
- Capacity input (number)
- Validation: time and capacity required
- Cancel/Confirm buttons
4. **Save Bar** (Fixed at bottom)
- Only shows when `isDirty` flag is true
- "保存全部更改" button with loading state
#### Key Functions
```typescript
async fetchTemplates()
- Fetches all templates from backend
- Groups by dayOfWeek for display
- Clears isDirty flag
async handleSave()
- Maps local template state to API payload
- Calls adminStore.saveWeekTemplates(payload)
- Refreshes templates after save
- Shows success/error toast
function openAdd()
- Opens modal for creating new template
- Clears form
function openEdit(tpl)
- Opens modal in edit mode
- Populates form with existing values
function submitForm()
- Validates form (time and capacity required)
- Creates or updates template in memory
- Sets isDirty = true (triggers save bar)
function toggleTemplate(tpl)
- Toggles isActive flag
- Sets isDirty = true
function deleteTemplate(tpl)
- Shows confirmation modal
- Removes from array
- Sets isDirty = true
```
#### Local State Management
```typescript
const templates = ref<LocalTemplate[]>([])
const loading = ref(false)
const saving = ref(false)
const isDirty = ref(false) // Tracks unsaved changes
const showModal = ref(false)
const editTarget = ref<LocalTemplate | null>(null)
const form = ref({
dayIdx: 0, // Selected day index (0-6)
startTime: '09:00',
endTime: '10:00',
capacityStr: '10',
})
const grouped = computed(() => {
// Groups templates by dayOfWeek for rendering
return Object.fromEntries(
Object.entries(map).sort(([a], [b]) => Number(a) - Number(b))
)
})
```
#### Example: Adding a Monday 9AM-10AM class
1. User taps "+ 新增时段"
2. Modal opens, form is reset to defaults
3. User selects "周一" (Monday) from picker
4. User confirms times and capacity
5. New template object is pushed to `templates` array
6. `isDirty` is set to true → save bar appears
7. User taps "保存全部更改"
8. Store calls `PUT /admin/week-template` with all templates
9. Backend deletes all old templates and creates new ones
10. Frontend refetches and displays updated list
---
### 3. **Slot Adjustment (slot-adjust.vue)** - Temporary Slot Management
**File**: `packages/app/src/pages/admin/slot-adjust.vue`
**Route**: `/pages/admin/slot-adjust`
#### Purpose
Handle temporary/manual time slot operations:
1. Add one-off slots for specific dates
2. Close available slots
3. Batch-generate slots from templates
#### UI Structure (3 Tabs)
##### Tab 0: "新增时段" (Add Manual Slot)
- Date picker (defaults to today)
- Start/End time pickers
- Capacity input
- Submit button: "新增时段"
- **Endpoint**: `POST /admin/time-slot/manual`
```typescript
interface CreateManualSlotDto {
date: string // YYYY-MM-DD
startTime: string // HH:MM
endTime: string // HH:MM
capacity?: number // Defaults to DEFAULT_SLOT_CAPACITY (1)
}
```
##### Tab 1: "关闭时段" (Close Slots)
- Date picker (defaults to today)
- Loads all slots for selected date
- Displays slot list with:
- Time range
- Status badge (OPEN/FULL/CLOSED)
- Booked count: "X/Y"
- Close button (if not already closed)
- Confirmation modal when closing
- **Endpoint**: `PUT /admin/time-slot/:id/close`
**Slot Status Colors**:
```
OPEN → Green badge #27ae60
FULL → Orange badge #e67e22
CLOSED → Gray badge #999
```
##### Tab 2: "批量生成" (Batch Generate)
- Start date picker
- End date picker (defaults to +7 days)
- Hint: "将根据排课模板,自动生成所选日期范围内的时段"
- *"Will auto-generate slots for selected date range based on schedule template"*
- Submit button: "批量生成"
- **Endpoint**: `POST /admin/generate-slots`
**How it works**:
1. Frontend sends date range to backend
2. Backend fetches all **active** WeekTemplates
3. For each day in range, finds matching templates by weekday
4. Creates TimeSlot records with `source: TEMPLATE`
5. Uses `skipDuplicates: true` to avoid re-generating existing slots
```typescript
// Backend example: If templates include:
// - Monday: 09:00-10:00, 18:00-19:00 (2 templates)
// - Wednesday: 10:00-11:00 (1 template)
//
// And user selects 2026-04-05 to 2026-04-11:
// - Mon 04-06: 2 slots generated
// - Wed 04-08: 1 slot generated
// Total: 3 slots (if these dates fall in range)
```
#### Key Functions
```typescript
async submitAddSlot()
- POST /admin/time-slot/manual
- Shows success/error toast
async loadSlotsForClose()
- Fetches slots for closeDate via adminStore.fetchSlotsByDate(date)
- Sets slotsLoading flag
async closeSlot(slot)
- Confirmation modal
- PUT /admin/time-slot/:id/close
- Reloads slot list
async submitGenerate()
- POST /admin/generate-slots with date range
- Shows toast with count of generated slots
```
#### Local State
```typescript
const activeTab = ref(0) // 0=Add, 1=Close, 2=Generate
const submitting = ref(false)
const slotsLoading = ref(false)
// Tab 0: Add form
const addForm = ref({
date: formatDate(new Date()),
startTime: '09:00',
endTime: '10:00',
capacityStr: '10',
})
// Tab 1: Close slots
const closeDate = ref(formatDate(new Date()))
const daySlots = ref<TimeSlot[]>([])
// Tab 2: Generate form
const genForm = ref({
startDate: formatDate(new Date()),
endDate: formatDate(new Date(Date.now() + 7 * 86400000)), // +7 days
})
```
---
### 4. **Admin Store (Pinia)**
**File**: `packages/app/src/stores/admin.ts`
#### API Methods Related to Scheduling
```typescript
// ── Week templates ───────────────────────────────────────────────
async fetchWeekTemplates(): Promise<WeekTemplate[]>
// GET /admin/week-template
// Returns all templates for current studio
// Usage: Gets templates for display in week-template.vue
async saveWeekTemplates(templates: WeekTemplateInput[]): Promise<WeekTemplate[]>
// PUT /admin/week-template
// Body: { templates: [...] }
// Replaces ALL templates with new set (delete all, create new)
// Note: Backend uses transaction for atomicity
// ── Time slots ───────────────────────────────────────────────────
async fetchSlotsByDate(date: string): Promise<TimeSlot[]>
// GET /admin/time-slots?date=YYYY-MM-DD
// Returns all slots for a specific date
// Used in slot-adjust.vue Tab 1 (close slots)
async createManualSlot(dto: CreateManualSlotDto): Promise<TimeSlot>
// POST /admin/time-slot/manual
// Creates a one-off time slot
// Used in slot-adjust.vue Tab 0
async closeSlot(id: string): Promise<TimeSlot>
// PUT /admin/time-slot/:id/close
// Changes slot status from OPEN to CLOSED
// Used in slot-adjust.vue Tab 1
async generateSlots(startDate: string, endDate: string): Promise<{ count: number }>
// POST /admin/generate-slots
// Generates slots from active templates for date range
// Used in slot-adjust.vue Tab 2
// Returns: { count: number of newly created slots }
// ── Dashboard ────────────────────────────────────────────────────
async fetchDashboardStats(): Promise<AdminStats>
// GET /admin/stats
// Returns: { todayBookings, totalOrders, totalBookings }
// Used in index.vue
```
#### API Response Types
```typescript
interface AdminStats {
todayBookings: number
totalOrders: number
totalBookings: number
}
interface WeekTemplate {
id: string
dayOfWeek: number // 1-7 (ISO weekday)
startTime: string // HH:MM
endTime: string // HH:MM
capacity: number
isActive: boolean
createdAt: string
updatedAt: string
}
interface TimeSlot {
id: string
date: string // YYYY-MM-DD
startTime: string
endTime: string
capacity: number
bookedCount: number
status: TimeSlotStatus // OPEN | FULL | CLOSED
source: TimeSlotSource // TEMPLATE | MANUAL
templateId: string | null
createdAt: string
updatedAt: string
}
```
---
## 🔌 Backend Architecture
### Time Slot Controller
**File**: `packages/server/src/time-slot/time-slot.controller.ts`
#### Member Endpoints (Public)
```
GET /time-slot/available?date=YYYY-MM-DD
- Get available slots for a date
- Include booking status for current user
GET /time-slot/:id
- Get specific slot details
```
#### Admin Endpoints (Requires JWT + ADMIN role)
```
GET /admin/week-template
- Returns all WeekTemplates
- Ordered by: dayOfWeek ASC, startTime ASC
PUT /admin/week-template
- Request body: { templates: [...] }
- Replaces all templates (transaction-based)
- Validation: dayOfWeek 1-7, startTime/endTime strings
POST /admin/time-slot/manual
- Request body: { date, startTime, endTime, capacity? }
- Creates manual slot with source=MANUAL
- Capacity defaults to DEFAULT_SLOT_CAPACITY
PUT /admin/time-slot/:id/close
- Changes slot status to CLOSED
- Returns updated slot
POST /admin/generate-slots
- Generates slots from active templates
- Fetches templates where isActive=true
- Creates slots for next SLOT_GENERATION_DAYS (14 days by default)
- Uses skipDuplicates to make re-runs safe
- Returns: { count: number }
```
### Time Slot Service
**File**: `packages/server/src/time-slot/time-slot.service.ts`
Key methods:
```typescript
async getWeekTemplates(): Promise<WeekTemplate[]>
// Returns all templates sorted by day/time
async replaceWeekTemplates(items: Array<{...}>): Promise<any>
// Transaction-based replacement:
// 1. Delete all existing templates
// 2. Create new ones from items array
// 3. Return count of created
async createManualSlot(dto): Promise<TimeSlot>
// Creates slot with source=MANUAL, status=OPEN
async closeSlot(id: string): Promise<TimeSlot>
// Updates status to CLOSED
```
### Slot Generator Service
**File**: `packages/server/src/time-slot/slot-generator.service.ts`
Key method:
```typescript
async generateSlots(daysAhead: number = 14): Promise<number>
// 1. Fetches all WeekTemplates where isActive=true
// 2. For each of next N days:
// - Calculate ISO weekday (1=Mon, 7=Sun)
// - Find matching templates by dayOfWeek
// - Create TimeSlot records with source=TEMPLATE, templateId=id
// 3. Uses createMany with skipDuplicates=true
// 4. Returns count of newly created slots
//
// Key: Converts JS getDay() (0=Sun) to ISO weekday (1=Mon, 7=Sun)
async cleanupExpiredSlots(): Promise<number>
// Called by scheduler
// Closes all OPEN slots with date < today
async checkExpiredMemberships(): Promise<number>
// Called by scheduler
// Expires memberships past end date or with 0 sessions left
async completeBookings(): Promise<number>
// Called by scheduler
// Marks CONFIRMED bookings as COMPLETED if slot date passed
```
---
## 📊 Data Flow: "排课设置" User Journey
### Scenario: Admin sets up class schedule for next week
1. **Admin opens dashboard**`index.vue`
- Taps "排课设置" nav item
2. **Admin navigates to Week Template page**`week-template.vue`
- `onMounted()``fetchTemplates()`
- Frontend: `GET /admin/week-template`
- Shows existing templates grouped by day
- Example display:
```
周一
09:00-10:00 10人 [启用] [编辑] [删除]
18:00-19:00 8人 [启用] [编辑] [删除]
周三
10:00-11:00 12人 [启用] [编辑] [删除]
```
3. **Admin adds a new class** → Click "+ 新增时段"
- Modal opens
- Select day, time, capacity
- Click "确认"
- Template added to local `templates` array
- **Save bar appears** at bottom
4. **Admin edits existing template** → Click "编辑"
- Modal opens with existing values
- Modify time/capacity
- Click "确认"
- Updated in local array
- Save bar shows if changed
5. **Admin disables a template** → Click "停用"
- `isActive` flipped to false
- Template grayed out
- Save bar shows
6. **Admin saves all changes** → Click "保存全部更改"
- Loading state
- Frontend: `PUT /admin/week-template` with all templates
- Backend transaction:
```
BEGIN TRANSACTION
DELETE FROM week_template
INSERT INTO week_template (day_of_week, start_time, end_time, capacity, is_active) VALUES (...)
COMMIT TRANSACTION
```
- Success toast
- Frontend refetches templates
- Save bar disappears
7. **Backend scheduler auto-generates slots**
- Nightly cron (scheduler module)
- Calls `SlotGeneratorService.generateSlots(14)`
- Queries active WeekTemplates
- For each day in next 14 days:
- Checks what templates apply (by ISO weekday)
- Creates TimeSlot records
- Uses `skipDuplicates` to avoid duplicates on re-run
- Example output:
```
date: 2026-04-06 (Monday)
09:00-10:00 source=TEMPLATE templateId=abc123
18:00-19:00 source=TEMPLATE templateId=def456
date: 2026-04-08 (Wednesday)
10:00-11:00 source=TEMPLATE templateId=ghi789
```
8. **Members can see and book the generated slots**
- Frontend: `GET /time-slot/available?date=2026-04-06`
- Members choose a slot and confirm booking
---
## 📅 Constants & Utilities
### Shared Constants
**File**: `packages/shared/src/constants.ts`
```typescript
export const SLOT_GENERATION_DAYS = 14
// Number of days ahead to generate slots for
export const DEFAULT_SLOT_CAPACITY = 1
// Default capacity if not specified (for private lessons)
export const WEEKDAY_LABELS = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日']
// Index 0 is unused, 1-7 map to weekdays
// Used in dropdowns and display
export const DEFAULT_CANCEL_HOURS_LIMIT = 2
// Hours before slot to allow free cancellation
export const TIME_PERIODS = {
MORNING: { label: '上午', start: '06:00', end: '12:00' },
AFTERNOON: { label: '下午', start: '12:00', end: '18:00' },
EVENING: { label: '晚上', start: '18:00', end: '22:00' }
}
export const DATE_SELECTOR_DAYS = 7
```
### Format Utilities
**File**: `packages/app/src/utils/format.ts`
```typescript
formatDate(date: Date | string): string
// Converts to YYYY-MM-DD format
// Used for date pickers and API calls
getWeekdayLabel(date: Date | string): string
// Returns Chinese weekday (周一-周日)
isToday(date: Date | string): boolean
// Checks if date is today
getDateRange(days: number): Array<{ date, weekday, isToday }>
// Generates future N days' dates
```
### Request Utility
**File**: `packages/app/src/utils/request.ts`
```typescript
function request<T>(options: RequestOptions): Promise<T>
// Makes HTTP request with JWT auth
// Auto-refreshes token on 401
function get<T>(url: string, data?: Record<string, unknown>): Promise<T>
function post<T>(url: string, data?: Record<string, unknown>): Promise<T>
function put<T>(url: string, data?: Record<string, unknown>): Promise<T>
function del<T>(url: string, data?: Record<string, unknown>): Promise<T>
// Base URL logic:
// - Production: https://focus.richarjiang.com/api
// - Development: http://localhost:3000/api
```
---
## 🔐 Permission Model
**Role**: `UserRole.ADMIN`
### Protected Endpoints
All `/admin/*` endpoints require:
1. Valid JWT token
2. Header: `Authorization: Bearer <token>`
3. User role must be `ADMIN`
Protected by:
- `@UseGuards(JwtAuthGuard, RolesGuard)`
- `@Roles(UserRole.ADMIN)`
### Auth Flow
1. Admin logs in via auth module
2. JWT token returned, stored in `uni.setStorageSync('token')`
3. All requests include token in Authorization header
4. If 401 response: clear token, show login prompt
5. If 4xx/5xx: show error toast
---
## 🐛 Current Implementation Notes
### Implemented Features ✅
- [x] Week template CRUD (Create, Read, Update via replace)
- [x] Manual slot creation
- [x] Close individual slots
- [x] Batch slot generation from templates
- [x] UI for all three slot adjustment tabs
- [x] Local state change tracking (isDirty)
- [x] Modal form for adding/editing templates
- [x] Grouping templates by weekday
- [x] Status badges for slots (OPEN/FULL/CLOSED)
### Missing/Stub Features ⚠️
- [ ] `fetchDashboardStats()` API endpoint appears to be stubbed
- `index.vue` calls it but endpoint not found in backend
- May need to implement in studio or payment controller
- [ ] No client-side validation errors displayed on API failures
- [ ] No confirmation before overwriting all templates
- [ ] No undo/restore from past template versions
### Edge Cases to Watch 🔍
1. **Timezone handling**: All dates are treated as UTC midnight
- Slot generation uses `setUTCHours(0,0,0,0)`
- Frontend format displays as YYYY-MM-DD (local string)
2. **Duplicate slot prevention**:
- Backend uses `skipDuplicates: true` in createMany
- Assumes date + startTime + endTime forms unique key
3. **Template replacement is atomic**:
- All templates deleted, all new ones created in transaction
- If one row fails, entire operation rolls back
4. **ISO weekday vs JS getDay()**:
- Shared code uses ISO: 1=Mon, 7=Sun
- Frontend picker displays Chinese labels
- Backend slot-generator converts JS getDay() to ISO
---
## 📱 UI Design Patterns
### Colors & Styling
- **Primary**: `#1a1a2e` (dark navy)
- **Accent**: `#c9a87c` (gold)
- **Success**: `#27ae60` (green)
- **Warning**: `#e67e22` (orange)
- **Danger**: `#c0392b` (red)
- **Background**: `#f5f3f0` (light beige)
### Component Patterns
1. **Skeleton loaders**: Shimmer animation for loading states
2. **Save bar**: Fixed bottom bar shows only when changes exist
3. **Toggle buttons**: Color indicates state (on=green, off=orange)
4. **Modals**: Bottom-sheet style with backdrop
5. **Pickers**: WeChat native pickers for date/time
6. **Badges**: Color-coded status indicators
---
## 🚀 Deployment & Configuration
### Frontend
- WeChat mini-program environment
- Base URL logic in `packages/app/src/utils/request.ts`:
```typescript
// Production
https://focus.richarjiang.com/api
// Development
http://localhost:3000/api
```
### Backend
- NestJS server on port 3000
- Prisma ORM with database
- JWT authentication
- Role-based access control (RBAC)
---
## 📚 Related Files Summary
| File | Purpose | Type |
|------|---------|------|
| `admin/index.vue` | Admin dashboard | Component |
| `admin/week-template.vue` | Schedule templates | Component ⭐ |
| `admin/slot-adjust.vue` | Manual slot ops | Component |
| `stores/admin.ts` | Admin API calls | Store |
| `time-slot.service.ts` | Slot business logic | Service |
| `slot-generator.service.ts` | Template-based generation | Service |
| `time-slot.controller.ts` | API endpoints | Controller |
| `week-template.ts` | Type definitions | Type |
| `constants.ts` | Shared constants | Config |
| `format.ts` | Date/time utilities | Utility |
---
## 🎯 Key Takeaways
1. **"排课设置"** is the master schedule template management page
2. **Templates are ISO-weekday based** (1=Monday, 7=Sunday)
3. **Slot generation is automated** via backend scheduler, triggered by:
- Nightly cron job
- Or manual POST to `/admin/generate-slots` endpoint
4. **Save pattern**: Local changes tracked, one "save all" API call with full template array
5. **Timezone**: All operations use UTC midnight as boundaries
6. **Atomicity**: Backend uses Prisma transactions for template replacement
7. **Permissions**: All admin endpoints protected by JWT + ADMIN role guard

View File

@@ -1,552 +0,0 @@
# Booking Page - Architecture Diagram
## 🏛️ Complete System Architecture
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ WECHAT MINI-PROGRAM │
│ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ FRONTEND (Vue 3 + Uni-app) │ │
│ │ │ │
│ │ ┌────────────────────────────────────────────────────────────────┐ │ │
│ │ │ pages/booking/index.vue (Main Page Component) │ │ │
│ │ │ ───────────────────────────────────────────────────────────── │ │ │
│ │ │ State: │ │ │
│ │ │ • selectedDate: string │ │ │
│ │ │ • selectedPeriod: PeriodKey | null │ │ │
│ │ │ • showConfirmPopup: boolean │ │ │
│ │ │ • pendingSlot: TimeSlotWithBookingStatus | null │ │ │
│ │ │ • refreshing: boolean │ │ │
│ │ │ │ │ │
│ │ │ Computed: │ │ │
│ │ │ • scrollHeight (responsive) │ │ │
│ │ │ • filteredSlots (depends on period) │ │ │
│ │ │ │ │ │
│ │ │ Lifecycle: │ │ │
│ │ │ • onMounted() → Load memberships + today's slots │ │ │
│ │ │ │ │ │
│ │ │ Event Handlers: │ │ │
│ │ │ • onDateSelect() → loadSlots(newDate) │ │ │
│ │ │ • onPeriodChange() → Auto-filter via computed │ │ │
│ │ │ • onRefresh() → Reload slots │ │ │
│ │ │ • onBookTap() → Auth check → Show popup │ │ │
│ │ │ • onConfirmBooking() → Create booking → Refresh │ │ │
│ │ │ • onCancelTap() → Cancel booking → Refresh │ │ │
│ │ └────────────────────────────────────────────────────────────────┘ │ │
│ │ ↓ │ │
│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │
│ │ │ Child Components (All reactive & event-driven) │ │ │
│ │ │ │ │ │
│ │ │ ┌──────────────────┐ ┌──────────────────┐ │ │ │
│ │ │ │ DateSelector.vue │ │ TimePeriod...vue │ │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ │ [Today] [5] [4] │ │ 全部 上午 下午... │ │ │ │
│ │ │ │ Props: modelValue│ │ Props: modelValue│ │ │ │
│ │ │ │ Emit: @select │ │ Emit: @change │ │ │ │
│ │ │ └──────────────────┘ └──────────────────┘ │ │ │
│ │ │ ↓ ↓ │ │ │
│ │ │ (Updates selectedDate) (Updates selectedPeriod) │ │ │
│ │ │ ↓ ↓ │ │ │
│ │ │ (Triggers loadSlots) (Recomputes filteredSlots) │ │ │
│ │ │ │ │ │
│ │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │
│ │ │ │ SlotCard.vue (Rendered via v-for over filteredSlots) │ │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ │
│ │ │ │ │ [09:00-10:00] [0/1 人] │ │ │ │ │
│ │ │ │ │ [可预约] │ │ │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ │ │ Props: slot (TimeSlotWithBookingStatus) │ │ │ │ │
│ │ │ │ │ Emit: @book | @cancel │ │ │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ │ │ Computed: │ │ │ │ │
│ │ │ │ │ • capacityLabel ("0/1 人" | "已关闭") │ │ │ │ │
│ │ │ │ │ • capacityClass (cap-open | cap-almost | ...) │ │ │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ │ │ Button States (4 conditions): │ │ │ │ │
│ │ │ │ │ 1. OPEN + not booked → "可预约" │ │ │ │ │
│ │ │ │ │ 2. OPEN + booked → "已预约" + "取消" │ │ │ │ │
│ │ │ │ │ 3. FULL → "已约满" │ │ │ │ │
│ │ │ │ │ 4. CLOSED → "已关闭" │ │ │ │ │
│ │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ │
│ │ │ │ ↓ ↓ │ │ │ │
│ │ │ │ (onBookTap) (onCancelTap) │ │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ │
│ │ │ │ │ BookingConfirmPopup.vue (Modal) │ │ │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ │ │ Props: │ │ │ │ │
│ │ │ │ │ • visible: boolean │ │ │ │ │
│ │ │ │ │ • slot: TimeSlotWithBookingStatus │ │ │ │ │
│ │ │ │ │ • memberships: MembershipWithCardType[] │ │ │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ │ │ State: │ │ │ │ │
│ │ │ │ │ • selectedMembershipId (auto-selected on show) │ │ │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ │ │ Display: │ │ │ │ │
│ │ │ │ │ ┌─────────────────────────────────┐ │ │ │ │ │
│ │ │ │ │ │ 确认预约 ✕ │ │ │ │ │
│ │ │ │ │ ├─────────────────────────────────┤ │ │ │ │ │
│ │ │ │ │ │ 日期: 2026-04-05 │ │ │ │ │ │
│ │ │ │ │ │ 时间: 09:00 - 10:00 │ │ │ │ │ │
│ │ │ │ │ │ 剩余: 1 个名额 │ │ │ │ │ │
│ │ │ │ │ ├─────────────────────────────────┤ │ │ │ │ │
│ │ │ │ │ │ 💳 私教课程 │ │ │ │ │ │
│ │ │ │ │ │ 剩余 10 次 ✓ │ │ │ │ │ │
│ │ │ │ │ ├─────────────────────────────────┤ │ │ │ │ │
│ │ │ │ │ │ [取消] [确认预约] │ │ │ │ │ │
│ │ │ │ │ └─────────────────────────────────┘ │ │ │ │ │
│ │ │ │ │ ↓ │ │ │ │ │
│ │ │ │ │ Emit: @confirm({timeSlotId, membershipId}) │ │ │ │ │
│ │ │ │ │ or @cancel │ │ │ │ │
│ │ │ │ └──────────────────────────────────────────────────────┘ │ │ │ │
│ │ │ └──────────────────────────────────────────────────────────┘ │ │ │
│ │ └────────────────────────────────────────────────────────────────┘ │ │
│ │ ↓ │ │
│ │ ┌────────────────────────────────────────────────────────────────┐ │ │
│ │ │ Pinia Stores (Reactive State Management) │ │ │
│ │ │ │ │ │
│ │ │ stores/booking.ts: │ │ │
│ │ │ State: │ │ │
│ │ │ • slots: TimeSlotWithBookingStatus[] │ │ │
│ │ │ • myBookings: BookingWithDetails[] │ │ │
│ │ │ • upcomingBookings: BookingWithDetails[] │ │ │
│ │ │ • loadingSlots: boolean │ │ │
│ │ │ • loadingBookings: boolean │ │ │
│ │ │ │ │ │
│ │ │ Actions: │ │ │
│ │ │ • fetchSlots(date) │ │ │
│ │ │ • createBooking(dto) │ │ │
│ │ │ • cancelBooking(bookingId) │ │ │
│ │ │ • fetchMyBookings() │ │ │
│ │ │ • fetchUpcomingBookings() │ │ │
│ │ │ │ │ │
│ │ │ stores/user.ts: │ │ │
│ │ │ State: │ │ │
│ │ │ • user: UserProfileResponse | null │ │ │
│ │ │ • memberships: MembershipWithCardType[] │ │ │
│ │ │ • token: string (from localStorage) │ │ │
│ │ │ │ │ │
│ │ │ Computed: │ │ │
│ │ │ • loggedIn: !!token │ │ │
│ │ │ • hasValidMembership: activeMemberships.length > 0 │ │ │
│ │ │ • activeMemberships: memberships filtered by ACTIVE │ │ │
│ │ │ │ │ │
│ │ │ Actions: │ │ │
│ │ │ • login() │ │ │
│ │ │ • fetchMemberships() │ │ │
│ │ │ • fetchProfile() │ │ │
│ │ │ • logout() │ │ │
│ │ └────────────────────────────────────────────────────────────────┘ │ │
│ │ ↓ │ │
│ │ ┌────────────────────────────────────────────────────────────────┐ │ │
│ │ │ Utils & Helpers │ │ │
│ │ │ │ │ │
│ │ │ utils/request.ts (HTTP Client): │ │ │
│ │ │ • request<T>(options): Promise<T> │ │ │
│ │ │ • get<T>(url, data?): Promise<T> │ │ │
│ │ │ • post<T>(url, data?): Promise<T> │ │ │
│ │ │ • put<T>(url, data?): Promise<T> │ │ │
│ │ │ │ │ │
│ │ │ utils/format.ts (Date Utilities): │ │ │
│ │ │ • formatDate(date): string │ │ │
│ │ │ • getWeekdayLabel(date): string │ │ │
│ │ │ • isToday(date): boolean │ │ │
│ │ │ • getDateRange(days): DateInfo[] │ │ │
│ │ └────────────────────────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ ↕ │
│ HTTP Requests │
│ (Bearer Token in Header) │
│ ↓ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ BACKEND API │ │
│ │ (packages/server/src/time-slot, booking, membership modules) │ │
│ │ │ │
│ │ GET /api/time-slot/available?date=YYYY-MM-DD │ │
│ │ → TimeSlotWithBookingStatus[] │ │
│ │ │ │
│ │ POST /api/booking │ │
│ │ Body: { timeSlotId, membershipId } │ │
│ │ → BookingWithDetails │ │
│ │ │ │
│ │ PUT /api/booking/:bookingId/cancel │ │
│ │ → BookingWithDetails (status: CANCELLED) │ │
│ │ │ │
│ │ GET /api/membership/my │ │
│ │ → MembershipWithCardType[] │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ ↕ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ DATABASE │ │
│ │ (TimeSlot, Booking, Membership, User tables) │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
```
---
## 📊 Data Flow Lifecycle
```
╔══════════════════════════════════════════════════════════════════════════╗
║ BOOKING PAGE LIFECYCLE ║
╚══════════════════════════════════════════════════════════════════════════╝
1. PAGE LOAD
┌─ onMounted()
│ ├─ IF loggedIn AND no memberships
│ │ └─ userStore.fetchMemberships()
│ │ GET /membership/my
│ │ → memberships array
│ │
│ └─ loadSlots(today)
│ → bookingStore.fetchSlots(date)
│ GET /time-slot/available?date=YYYY-MM-DD
│ → slots array
│ → Render SlotCard components
└─ READY ✓
2. USER SELECTS DATE
├─ onDateSelect(newDate)
├─ selectedDate.value = newDate
└─ loadSlots(newDate)
→ bookingStore.fetchSlots(newDate)
→ slots array (for new date)
→ SlotCard components re-render
3. USER SELECTS TIME PERIOD
├─ onPeriodChange(period)
├─ selectedPeriod.value = period
└─ filteredSlots computed updates automatically
→ Vue watches TIME_PERIODS[period]
→ Filters slots by startTime
→ SlotCard components re-render (subset)
4. USER PULLS TO REFRESH
├─ onRefresh()
├─ refreshing.value = true
├─ loadSlots(selectedDate.value)
│ → bookingStore.fetchSlots()
│ → slots array (refreshed)
└─ refreshing.value = false
5. USER TAPS "可预约" (Book)
├─ onBookTap(slot)
├─ CHECK: loggedIn?
│ ├─ NO → Show login modal
│ │ User clicks confirm
│ │ → userStore.login()
│ │ POST /auth/wxLogin
│ │ → token + user
│ │ → userStore.fetchMemberships()
│ │ GET /membership/my
│ │ → memberships array
│ │ → RETRY onBookTap(slot)
│ │
│ └─ YES → Continue
├─ CHECK: hasValidMembership?
│ ├─ NO → Show purchase modal
│ │ User clicks confirm
│ │ → uni.navigateTo('/pages/store/index')
│ │
│ └─ YES → Continue
├─ pendingSlot.value = slot
├─ showConfirmPopup.value = true
└─ POPUP SHOWN ✓
├─ selectedMembershipId auto-selected (first one)
├─ Watch on popup visibility + memberships
│ → Auto-select first membership when shown
└─ User sees:
• Slot date/time
• Membership card options
• Deduction message
6. USER CONFIRMS BOOKING
├─ onConfirmBooking({timeSlotId, membershipId})
├─ showConfirmPopup.value = false
├─ uni.showLoading('预约中...')
├─ bookingStore.createBooking(payload)
│ └─ POST /booking
│ Body: { timeSlotId, membershipId }
│ → BookingWithDetails
├─ uni.hideLoading()
├─ uni.showToast('预约成功!')
├─ loadSlots(selectedDate.value) // REFRESH
│ → bookingStore.fetchSlots()
│ GET /time-slot/available?date=
│ → slots array (UPDATED)
│ • slot.isBookedByMe = true
│ • slot.myBookingId = bookingId
│ • Button now shows "已预约"
└─ BOOKING COMPLETE ✓
7. USER TAPS "取消" (Cancel)
├─ onCancelTap(slot)
├─ Show confirmation modal
├─ User confirms
├─ uni.showLoading('取消中...')
├─ bookingStore.cancelBooking(slot.myBookingId)
│ └─ PUT /booking/:id/cancel
│ → BookingWithDetails (status: CANCELLED)
├─ uni.hideLoading()
├─ uni.showToast('已取消预约')
├─ loadSlots(selectedDate.value) // REFRESH
│ → bookingStore.fetchSlots()
│ GET /time-slot/available?date=
│ → slots array (UPDATED)
│ • slot.isBookedByMe = false
│ • slot.myBookingId = null
│ • Button now shows "可预约"
└─ CANCELLATION COMPLETE ✓
```
---
## 🔄 State Synchronization
```
Component ←→ Pinia Store ←→ API ←→ Database
┌──────────────────────────────────────────────────────────┐
│ Component (Vue Template) │
│ │
│ {{ bookingStore.slots }} ← Reactive binding │
│ {{ filteredSlots }} ← Computed from slots │
│ {{ userStore.hasValidMembership }} ← Computed from store │
│ │
│ @click="onBookTap(slot)" ← User action │
│ │
└──────────────────────────────────────────────────────────┘
↑ ↓
│ Read │ Mutate
│ ↓
┌──────────────────────────────────────────────────────────┐
│ Pinia Store State │
│ │
│ slots: TimeSlotWithBookingStatus[] │
│ ↓ Recomputed when: │
│ - fetchSlots() returns data │
│ - createBooking() succeeds │
│ - cancelBooking() succeeds │
│ │
│ memberships: MembershipWithCardType[] │
│ ↓ Set when: │
│ - fetchMemberships() returns data │
│ │
│ loadingSlots: boolean │
│ ↓ Set to: │
│ - true on fetchSlots() start │
│ - false on fetchSlots() end │
│ │
└──────────────────────────────────────────────────────────┘
↑ ↓
│ Response │ Request
│ ↓
┌──────────────────────────────────────────────────────────┐
│ API Layer (utils/request.ts) │
│ │
│ GET /time-slot/available?date=2026-04-05 │
│ ↓ Returns ApiResponse<TimeSlotWithBookingStatus[]> │
│ { success: true, data: [...], message: null } │
│ │
│ POST /booking │
│ ↓ Body: { timeSlotId, membershipId } │
│ ↓ Returns ApiResponse<BookingWithDetails> │
│ { success: true, data: {...}, message: null } │
│ │
│ PUT /booking/:id/cancel │
│ ↓ Returns ApiResponse<BookingWithDetails> │
│ { success: true, data: {...}, message: null } │
│ │
└──────────────────────────────────────────────────────────┘
↑ ↓
│ SELECT/UPDATE │ INSERT/UPDATE
│ ↓
┌──────────────────────────────────────────────────────────┐
│ Database │
│ │
│ TimeSlot Table │
│ id, date, startTime, endTime, capacity, │
│ bookedCount, status, source, templateId │
│ │
│ Booking Table │
│ id, userId, timeSlotId, membershipId, │
│ status (CONFIRMED/CANCELLED/...), bookedAt │
│ │
│ Membership Table │
│ id, userId, cardTypeId, status, remainingTimes, │
│ expireDate, createdAt, updatedAt │
│ │
└──────────────────────────────────────────────────────────┘
```
---
## 🎯 Component Communication
```
Root: pages/booking/index.vue
├─ PROPS DOWN ──→ DateSelector.vue
│ └─ modelValue: string (YYYY-MM-DD)
├─ PROPS DOWN ──→ TimePeriodFilter.vue
│ └─ modelValue: PeriodKey | null
├─ PROPS DOWN ──→ SlotCard.vue (v-for)
│ └─ slot: TimeSlotWithBookingStatus
├─ PROPS DOWN ──→ BookingConfirmPopup.vue
│ ├─ visible: boolean
│ ├─ slot: TimeSlotWithBookingStatus | null
│ └─ memberships: MembershipWithCardType[]
├─ EVENTS UP ←── DateSelector.vue
│ ├─ @select(date) → onDateSelect()
│ └─ @update:modelValue(date)
├─ EVENTS UP ←── TimePeriodFilter.vue
│ ├─ @change(period) → onPeriodChange()
│ └─ @update:modelValue(period)
├─ EVENTS UP ←── SlotCard.vue
│ ├─ @book(slot) → onBookTap()
│ └─ @cancel(slot) → onCancelTap()
└─ EVENTS UP ←── BookingConfirmPopup.vue
├─ @confirm({timeSlotId, membershipId}) → onConfirmBooking()
└─ @cancel → showConfirmPopup = false
```
---
## 🧬 Reactive Dependency Chain
```
LocalStorage (token)
userStore.token
userStore.loggedIn (computed)
pages/booking → Check login status
userStore.memberships
userStore.activeMemberships (computed, filtered by ACTIVE)
userStore.hasValidMembership (computed)
pages/booking → Show/hide booking button & membership popup
BookingConfirmPopup ← receives activeMemberships as props
selectedMembershipId (auto-selected on popup show)
bookingStore.slots (array)
pages/booking.selectedPeriod
pages/booking.filteredSlots (computed, filtered by TIME_PERIODS)
v-for → SlotCard components render
Each SlotCard → capacityLabel (computed)
→ capacityClass (computed)
→ Button state determined
bookingStore.loadingSlots (boolean)
pages/booking template
v-if → Show skeleton | Show slots | Show empty state
```
---
## 📋 API Request/Response Chain
```
USER TAPS DATE
pages/booking/onDateSelect()
loadSlots(date)
bookingStore.fetchSlots(date)
get('/time-slot/available', { date })
utils/request.get()
uni.request({
url: 'http://localhost:3000/api/time-slot/available',
method: 'GET',
data: { date: '2026-04-05' },
header: {
'Content-Type': 'application/json',
'Authorization': 'Bearer <token>'
}
})
BACKEND: GET /api/time-slot/available?date=2026-04-05
(Queries database for TimeSlot records matching date)
(Fetches current user's bookings for those slots)
(Enriches response with isBookedByMe, myBookingId)
Response: {
"success": true,
"data": [
{
"id": "...",
"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
}
request.ts success callback
├─ Check: statusCode < 400 ✓
├─ Check: body.success === true ✓
├─ Extract: body.data (TimeSlotWithBookingStatus[])
└─ Resolve promise with data
bookingStore.fetchSlots() try block
├─ slots.value = data
└─ loadingSlots.value = false
Component template reactivity
├─ Re-render with new slots
├─ Compute filteredSlots
└─ Render SlotCard components
```

View File

@@ -1,894 +0,0 @@
# 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

View File

@@ -1,395 +0,0 @@
# Booking Page Documentation
## 📚 Overview
This folder contains comprehensive documentation for the WeChat Mini-Program booking system in the mp-pilates project (Uni-app + Vue 3).
### 📄 Documentation Files
1. **BOOKING_PAGE_ANALYSIS.md** ⭐ START HERE
- Complete file-by-file breakdown of all components
- Data flow diagrams
- API contract documentation
- Color scheme and styling details
- Potential issues and problems
2. **COMPONENT_HIERARCHY.md**
- Visual component tree structure
- State management flow (Pinia stores)
- API sequence diagrams
- State machine for slot cards
- Data transformations
3. **QUICK_REFERENCE.md**
- Code snippets for quick lookup
- Debugging tips and console commands
- Common issues and solutions
- Debugging checklist
- API examples
---
## 🎯 Quick Navigation
### I want to understand...
**...the overall flow**
→ Read: BOOKING_PAGE_ANALYSIS.md → "Complete Data Flow Diagram" section
**...how the UI is structured**
→ Read: COMPONENT_HIERARCHY.md → "Component Tree" + "UI Layout Breakdown"
**...where specific code is**
→ Read: QUICK_REFERENCE.md → "Finding Specific Things"
**...how to debug an issue**
→ Read: QUICK_REFERENCE.md → "Common Issues & Solutions"
**...the API contracts**
→ Read: QUICK_REFERENCE.md → "API Contract Summary"
**...the store state**
→ Read: COMPONENT_HIERARCHY.md → "State Management Flow"
---
## 🏗️ Project Structure
```
packages/app/src/
├── pages/
│ └── booking/
│ └── index.vue # Main booking page (311 lines)
├── components/
│ ├── DateSelector.vue # Date picker (50 lines)
│ ├── TimePeriodFilter.vue # Time period filter (50 lines)
│ ├── SlotCard.vue # Individual slot card (230 lines)
│ └── BookingConfirmPopup.vue # Booking confirmation modal (430 lines)
├── stores/
│ ├── booking.ts # Booking state (72 lines)
│ └── user.ts # User/membership state (110 lines)
└── utils/
├── request.ts # API request utilities (80 lines)
└── format.ts # Date formatting utilities (50 lines)
packages/shared/src/
├── types/
│ ├── time-slot.ts # TimeSlot types
│ ├── api.ts # API response types
│ └── booking.ts # Booking types
├── constants.ts # TIME_PERIODS, etc
└── enums.ts # Enums (TimeSlotStatus, etc)
```
---
## 🔄 Data Flow at a Glance
```
Page Load
[Check login + load memberships]
Store: fetchSlots(today)
API: GET /time-slot/available?date=TODAY
State: bookingStore.slots = [TimeSlotWithBookingStatus[], ...]
Computed: filteredSlots (optionally filtered by period)
Render: SlotCard components
User interaction:
- Tap date → loadSlots(newDate)
- Filter period → filteredSlots re-computed
- Book slot → onBookTap() → popup
- Confirm → createBooking() → refresh slots
- Cancel → cancelBooking() → refresh slots
```
---
## 🎭 Key Components
### 1. pages/booking/index.vue
**Role:** Main page that orchestrates everything
**State:** selectedDate, selectedPeriod, showConfirmPopup, pendingSlot
**Stores:** bookingStore, userStore
**Key computed:** scrollHeight, filteredSlots
### 2. components/SlotCard.vue
**Role:** Displays individual time slot
**Props:** slot (TimeSlotWithBookingStatus)
**Emits:** book, cancel
**States:** 4 button states based on status + isBookedByMe
### 3. components/DateSelector.vue
**Role:** Horizontal date picker
**Props:** modelValue (YYYY-MM-DD)
**Data:** dateRange (7 days from today)
**Display:** Shows weekday, day number, month
### 4. components/TimePeriodFilter.vue
**Role:** Horizontal tab filter
**Props:** modelValue (MORNING|AFTERNOON|EVENING|null)
**Constants:** TIME_PERIODS from shared
### 5. components/BookingConfirmPopup.vue
**Role:** Modal for confirming booking
**Props:** visible, slot, memberships
**State:** selectedMembershipId (auto-selected on show)
**Logic:** Auto-select first membership when popup opens
### 6. stores/booking.ts
**Actions:**
- fetchSlots(date) → GET /time-slot/available?date=
- createBooking(dto) → POST /booking
- cancelBooking(bookingId) → PUT /booking/:id/cancel
- fetchMyBookings(status?) → GET /booking/my
- fetchUpcomingBookings() → GET /booking/my/upcoming
### 7. stores/user.ts
**Computed:**
- loggedIn: !!token.value
- hasValidMembership: activeMemberships.length > 0
- activeMemberships: memberships filtered by ACTIVE status
---
## 📊 State Types
### TimeSlotWithBookingStatus
```typescript
{
id: string // UUID
date: "2026-04-05" // YYYY-MM-DD
startTime: "09:00" // HH:MM
endTime: "10:00" // HH:MM
capacity: 1 // Max slots
bookedCount: 0 // Currently booked
status: "OPEN" | "FULL" | "CLOSED"
source: "MANUAL" | "TEMPLATE"
templateId: null
isBookedByMe: boolean // User has booked this
myBookingId: string | null // Booking ID (for cancel)
}
```
### MembershipWithCardType
```typescript
{
id: string
cardType: { name: string, ... }
status: "ACTIVE" | "EXPIRED" | "USED_UP"
remainingTimes: number | null
expireDate: "2026-12-31"
}
```
---
## 🎨 Visual States
### Slot Card Button States
| Condition | Button | Color | Action |
|-----------|--------|-------|--------|
| OPEN, not booked | "可预约" | Tan (#c9a87c) | Show popup |
| OPEN, booked by me | "已预约" + "取消" link | Tan + Red | Show cancel confirm |
| FULL | "已约满" | Gray (#f0f0f0) | Disabled |
| CLOSED | "已关闭" | Gray (#f0f0f0) | Disabled |
### Capacity Badge Colors
| Condition | Background | Text | Meaning |
|-----------|------------|------|---------|
| <80% booked | #f0faf3 | #4caf50 | Green - Plenty of spots |
| ≥80% booked | #fff8ed | #f59e0b | Orange - Almost full |
| FULL | #fef0f0 | #ef4444 | Red - No spots |
| CLOSED | #f5f5f5 | #999 | Gray - Unavailable |
---
## 🔐 Authentication
- Token stored in localStorage
- Automatically included in request headers
- 401 response → Clear token + show "please login" toast
- onBookTap checks loggedIn → shows login modal if needed
- onBookTap checks hasValidMembership → shows purchase modal if needed
---
## 📡 API Endpoints
### GET /time-slot/available?date=YYYY-MM-DD
```
Query: date (required, YYYY-MM-DD format)
Returns: TimeSlotWithBookingStatus[]
Auth: Bearer token required
```
### POST /booking
```
Body: { timeSlotId, membershipId }
Returns: BookingWithDetails
Auth: Bearer token required
```
### PUT /booking/:bookingId/cancel
```
Path: bookingId
Returns: BookingWithDetails (with status: CANCELLED)
Auth: Bearer token required
```
### GET /membership/my
```
Returns: MembershipWithCardType[]
Auth: Bearer token required
```
---
## ⚠️ Known Issues
### 1. GET Request Body Issue
- File: `utils/request.ts`, `get()` function
- Problem: Data passed as body instead of query params
- Impact: Might not work on all platforms
### 2. Error Handling
- File: `stores/booking.ts`, `fetchSlots()`
- Problem: Network error → empty array instead of error message
- Impact: Users can't tell if error or truly no slots
### 3. Loading State
- File: `pages/booking/index.vue`
- Problem: Skeleton only appears on initial load
- Impact: Date changes appear instant (confusing on slow network)
### 4. Date Math
- File: `utils/format.ts`, `getDateRange()`
- Problem: Uses ms arithmetic (86400000ms per day)
- Impact: Doesn't account for DST transitions
---
## 🧪 Testing Checklist
### Happy Path
- [ ] Load page → today's slots display
- [ ] Tap date → slots change for that date
- [ ] Filter by period → slots filtered correctly
- [ ] Tap "可预约" → popup shows
- [ ] Confirm booking → slot shows "已预约"
- [ ] Tap "取消" → booking cancelled, slot resets
- [ ] Pull to refresh → slots reload
### Edge Cases
- [ ] No slots for date → empty state appears
- [ ] Not logged in → login modal on book tap
- [ ] No valid membership → purchase modal on book tap
- [ ] Network error → ??? (currently shows empty)
- [ ] Slot becomes FULL → button updates to disabled
- [ ] Multiple memberships → can select different card
---
## 📝 File Sizes
| File | Lines | Purpose |
|------|-------|---------|
| pages/booking/index.vue | 311 | Main page orchestration |
| components/BookingConfirmPopup.vue | 430 | Booking modal |
| components/SlotCard.vue | 230 | Slot display |
| stores/booking.ts | 72 | Booking state |
| utils/request.ts | 80 | API client |
| components/DateSelector.vue | 50 | Date picker |
| components/TimePeriodFilter.vue | 50 | Period filter |
| utils/format.ts | 50 | Date utilities |
---
## 🎓 Learning Path
**Level 1: Overview**
1. Read this file
2. Look at BOOKING_PAGE_ANALYSIS.md → "Complete Data Flow Diagram"
**Level 2: Components**
1. Read COMPONENT_HIERARCHY.md → "Component Tree"
2. Read BOOKING_PAGE_ANALYSIS.md → "File-by-File Analysis"
**Level 3: Implementation**
1. Read QUICK_REFERENCE.md → "Where Slots Come From"
2. Read actual source files in order:
- stores/booking.ts
- pages/booking/index.vue
- components/SlotCard.vue
- components/BookingConfirmPopup.vue
**Level 4: Debugging**
1. Read QUICK_REFERENCE.md → "Debugging Tips"
2. Read QUICK_REFERENCE.md → "Common Issues & Solutions"
**Level 5: Deep Dive**
1. Read COMPONENT_HIERARCHY.md → "State Management Flow"
2. Read COMPONENT_HIERARCHY.md → "API Calls Sequence"
3. Study utils/request.ts for request handling
---
## 🔗 Related Documentation
- Backend: `/packages/server/src/time-slot/`
- Shared types: `/packages/shared/src/types/`
- Auth: `/packages/app/src/utils/auth.ts`
- User store: `/packages/app/src/stores/user.ts`
---
## 📞 Quick Answers
**Q: Why doesn't the page load?**
A: Check 1) Is API returning data? 2) Is token valid? 3) Check console for errors
**Q: Why doesn't filtering work?**
A: Check 1) Is selectedPeriod.value being set? 2) Is slot.startTime correct format?
**Q: Why doesn't the booking button work?**
A: Check 1) Is slot.status === OPEN? 2) Is isBookedByMe === false? 3) Is user logged in?
**Q: How do I add error handling?**
A: See QUICK_REFERENCE.md → "Issue 1: Slots not loading" → Solution
**Q: How do I test the booking flow?**
A: See "Testing Checklist" section above
---
## 🚀 Common Tasks
### Add loading indicator during date change
→ Use bookingStore.loadingSlots in template
### Show error message for API failures
→ Add error state to bookingStore, show in template
### Change colors/styling
→ Edit style blocks in .vue files (see color scheme in BOOKING_PAGE_ANALYSIS.md)
### Modify time period ranges
→ Edit TIME_PERIODS in packages/shared/src/constants.ts
### Change initial date or time range
→ Edit pages/booking/index.vue onMounted() or DATE_SELECTOR_DAYS constant
### Add/remove date selector days
→ Edit DATE_SELECTOR_DAYS in packages/shared/src/constants.ts
---
Generated: 2026-04-05
Last Updated: BOOKING_PAGE_ANALYSIS.md

View File

@@ -1,218 +0,0 @@
# Card Types Bug Fix - Completion Index
## Quick Links
**Bug Fix Commit**: [a85270e](https://github.com/richarjiang/mp-pilates/commit/a85270e)
**Files Modified**:
- `packages/app/src/pages/admin/card-types.vue` - Added `.stop` modifiers to 3 action buttons
**Documentation Files**:
- `CARD_TYPES_BUG_FIX.md` - Complete bug explanation and fix details
- `MODAL_EVENT_HANDLING_AUDIT.md` - Audit of all application modals
- `CARD_TYPES_ANALYSIS.md` - Deep technical analysis
- `CARD_TYPES_QUICK_REFERENCE.md` - Quick lookup guide
- `EXPLORATION_SUMMARY.md` - Full system overview
---
## The Bug in 30 Seconds
**Problem**: Edit modal closes immediately after opening
**Cause**: Vue event propagation - tap events bubble from action buttons to modal-mask's close handler
**Solution**: Add `.stop` modifier to prevent event bubbling
**Impact**: Users can now edit card types successfully
---
## What Was Changed
### File: packages/app/src/pages/admin/card-types.vue
Three lines modified:
```diff
- <view class="ct-action-btn edit-btn" @tap="openEdit(ct)">
+ <view class="ct-action-btn edit-btn" @tap.stop="openEdit(ct)">
- <view class="ct-action-btn toggle-btn" @tap="toggleActive(ct)">
+ <view class="ct-action-btn toggle-btn" @tap.stop="toggleActive(ct)">
- <view class="ct-action-btn delete-btn" @tap="confirmDelete(ct)">
+ <view class="ct-action-btn delete-btn" @tap.stop="confirmDelete(ct)">
```
---
## Why It Works
The `.stop` modifier calls `event.stopPropagation()`, which prevents the tap event from bubbling to parent elements. This prevents the modal-mask's close handler from being triggered.
**Event flow with fix**:
1. User taps action button ✓
2. Event handler executes (edit/toggle/delete) ✓
3. Event propagation is stopped ✗ (no bubbling)
4. Modal-mask close handler is NOT triggered ✓
5. Modal stays open ✓
---
## Testing Instructions
### Quick Test
1. Go to Admin → Card Types
2. Click any [编辑] (Edit) button
3. Modal should open and stay open
4. Edit a field and click [确认] (Confirm)
5. Changes should save
### Full Test Suite
See `CARD_TYPES_BUG_FIX.md` for complete testing checklist
---
## Documentation Overview
### Bug Fix Documentation
- **CARD_TYPES_BUG_FIX.md** - Complete fix documentation with testing instructions
- **MODAL_EVENT_HANDLING_AUDIT.md** - Audit of all modals + preventive measures
### Feature Documentation
- **CARD_TYPES_ANALYSIS.md** - Deep dive into card types system
- **CARD_TYPES_QUICK_REFERENCE.md** - Quick lookup guide
- **EXPLORATION_SUMMARY.md** - Full system overview
- **CARD_TYPES_INDEX.md** - Master index
### Diagrams
- **CARD_TYPES_FLOW_DIAGRAM.txt** - ASCII art workflows
---
## Key Findings from Audit
**card-types.vue** - FIXED (event propagation issue resolved)
**week-template.vue** - SAFE (separate DOM structure)
**members.vue** - SAFE (single tap handler pattern)
**BookingConfirmPopup.vue** - SAFE (dedicated component)
**Conclusion**: No other files have the same issue.
---
## Commit Information
```
Hash: a85270e
Author: richarjiang <richarjiang@tencent.com>
Date: Sun Apr 5 12:53:03 2026 +0800
Message: fix(admin): prevent edit modal from closing immediately on tap
Fix the card types management edit modal that was closing
immediately after opening due to event propagation. Added
.stop modifier to all action button tap handlers (edit, toggle,
delete) to prevent bubbling to parent modal-mask element.
- Changed @tap="openEdit(ct)" to @tap.stop="openEdit(ct)"
- Changed @tap="toggleActive(ct)" to @tap.stop="toggleActive(ct)"
- Changed @tap="confirmDelete(ct)" to @tap.stop="confirmDelete(ct)"
This fixes the bug where the edit modal would open and close in
the same event cycle, making it impossible to edit card types.
```
---
## Files Changed Summary
| File | Changes | Lines | Type |
|------|---------|-------|------|
| card-types.vue | `.stop` modifiers added | 3 | Fix |
| CARD_TYPES_BUG_FIX.md | New documentation | 132 | Doc |
| MODAL_EVENT_HANDLING_AUDIT.md | New audit report | 200+ | Doc |
**Total**: 2 files modified/created
---
## Next Steps
### Immediate (Before Merge)
1. ✅ Code changes applied
2. ✅ Commit created
3. ✅ Documentation completed
4. □ Manual testing required
5. □ Code review approval needed
### For Deployment
1. Test the fix manually
2. Review commit in GitHub
3. Get team approval
4. Merge to main branch
5. Deploy to staging
6. Deploy to production
### For Prevention
1. Review `MODAL_EVENT_HANDLING_AUDIT.md` guidelines
2. Apply best practices to new code
3. Add E2E tests for modal interactions
4. Consider ESLint rules for modal event handling
---
## Technical Deep Dive
### Problem Pattern
This is a classic Vue event propagation issue that occurs when:
1. List items have action buttons
2. Tap handlers on buttons trigger state changes
3. Modal appears as overlay
4. Modal-mask has a tap handler to close
5. Event bubbles from button → card → list → modal-mask
### Solution Pattern
The fix is to add `.stop` modifier to any event handler that triggers state changes that render overlays:
```vue
<!-- Before: Event bubbles to parent handlers -->
<button @tap="openModal(item)">Edit</button>
<!-- After: Event stops propagating -->
<button @tap.stop="openModal(item)">Edit</button>
```
### Why This Is Safe
- `.stop` only prevents propagation, not default behavior
- Event still executes on the clicked element
- All three buttons work independently
- No side effects or unexpected behavior
- Follows Vue best practices
---
## References
- **Vue Event Modifiers**: https://vuejs.org/guide/essentials/event-handling.html#event-modifiers
- **Event Propagation**: https://developer.mozilla.org/en-US/docs/Web/API/Event/stopPropagation
- **Uni-app Events**: https://uniapp.dcloud.io/api/ui/intersection-observer
---
## Support & Questions
For questions about this fix:
1. Read `CARD_TYPES_BUG_FIX.md` for detailed explanation
2. Check `MODAL_EVENT_HANDLING_AUDIT.md` for similar patterns
3. Review the commit diff for exact changes
4. Consult Vue 3 event handling documentation
---
**Status**: ✅ COMPLETE - Ready for testing and deployment
**Last Updated**: 2026-04-05

View File

@@ -1,548 +0,0 @@
# Card Types Management Feature - Comprehensive Analysis
## Project Structure
- **Frontend**: `packages/app` (Vue 3 + Uni-app mini-program)
- **Backend**: `packages/server` (NestJS)
- **Shared**: `packages/shared` (types, enums, DTOs)
---
## 1. DATABASE SCHEMA (Prisma)
### CardType Model
**File**: `packages/server/prisma/schema.prisma` (lines 73-91)
```prisma
model CardType {
id String @id @default(uuid())
name String
type CardTypeCategory // TIMES | DURATION | TRIAL
totalTimes Int? // For TIMES/TRIAL cards
durationDays Int // How many days card is valid
price Decimal(10, 0) // Current price (in cents internally)
originalPrice Decimal?(10, 0) // Optional strikethrough price
description String?
isActive Boolean @default(true) // For 上架/下架
sortOrder Int @default(0) // Display order
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
memberships Membership[]
orders Order[]
}
```
### Card Type Category Enum
**File**: `packages/server/prisma/schema.prisma` (lines 17-21)
```prisma
enum CardTypeCategory {
TIMES // Time-based card (e.g., 10 classes)
DURATION // Month card (e.g., 30 days)
TRIAL // Trial card
}
```
**Shared Enum**: `packages/shared/src/enums.ts` (lines 8-12)
```typescript
export enum CardTypeCategory {
TIMES = 'TIMES',
DURATION = 'DURATION',
TRIAL = 'TRIAL',
}
```
---
## 2. SHARED TYPES & DTOs
### CardType Interface
**File**: `packages/shared/src/types/card-type.ts`
```typescript
export interface CardType {
readonly id: string
readonly name: string
readonly type: CardTypeCategory // TIMES | DURATION | TRIAL
readonly totalTimes: number | null // null for DURATION cards
readonly durationDays: number
readonly price: number // In cents, e.g., 98000 = ¥980
readonly originalPrice: number | null
readonly description: string | null
readonly isActive: boolean // true = 销售中, false = 已下架
readonly sortOrder: number
readonly createdAt: string
readonly updatedAt: string
}
```
### CreateCardTypeDto
```typescript
export interface CreateCardTypeDto {
readonly name: string
readonly type: CardTypeCategory
readonly totalTimes?: number
readonly durationDays: number
readonly price: number
readonly originalPrice?: number
readonly description?: string
readonly sortOrder?: number
}
```
### UpdateCardTypeDto
```typescript
export interface UpdateCardTypeDto {
readonly name?: string
readonly totalTimes?: number
readonly durationDays?: number
readonly price?: number
readonly originalPrice?: number
readonly description?: string
readonly isActive?: boolean // For toggling 上架/下架
readonly sortOrder?: number
}
```
---
## 3. SERVER-SIDE IMPLEMENTATION
### Membership Controller
**File**: `packages/server/src/membership/membership.controller.ts`
**Endpoints**:
```typescript
// Public (no auth)
GET /membership/card-types getActiveCardTypes()
// Admin only (JWT + RolesGuard)
GET /admin/card-types getAllCardTypes()
POST /admin/card-types createCardType(dto)
PUT /admin/card-types/:id updateCardType(id, dto)
DELETE /admin/card-types/:id deleteCardType(id)
```
### Membership Service
**File**: `packages/server/src/membership/membership.service.ts`
#### getActiveCardTypes()
- Returns only cards where `isActive: true`
- Sorted by `sortOrder` (ascending)
- Used by regular users/public
#### getAllCardTypes()
- Returns all cards (including inactive)
- Sorted by `sortOrder`
- Admin-only
#### createCardType(dto: CreateCardTypeDto)
- Creates a new card type
- Sets `isActive: true` by default
- `totalTimes` and `description` are optional (default to null)
#### updateCardType(id: string, dto: UpdateCardTypeDto)
- Updates card (all fields optional)
- **Can toggle `isActive`** for 上架/下架
- Can update name, price, duration, etc.
#### deleteCardType(id: string)
- **Soft delete**: doesn't remove from DB
- Sets `isActive: false` instead
- Updates the record
---
## 4. FRONTEND ADMIN PAGE
### Card-Types Page
**File**: `packages/app/src/pages/admin/card-types.vue`
#### Layout Structure:
1. **Toolbar** (top)
- Shows count: "共 X 个卡种"
- "+ 新增卡种" button → `openAdd()`
2. **Card List** (scrollable)
- Each card shows:
- Header band (colored by type: 次卡/月卡/体验卡)
- Status tag (销售中 or 已下架)
- Card name, price, description
- Meta info: times, duration, sort order
- Three action buttons: 编辑, 下架/上架, 删除
3. **Modal/Popup** (add/edit form)
- Title: "新增卡种" or "编辑卡种"
- Input fields:
* 卡种名称 (name)
* 类型 (picker: 次卡, 月卡, 体验卡)
* 现价 (price, digit input)
* 原价 (originalPrice, optional)
* 次数 (totalTimes, optional, required for 次卡)
* 有效天数 (durationDays, required)
* 排序值 (sortOrder, defaults to 0)
* 描述 (description, optional textarea)
- Cancel and Confirm buttons
#### Key Ref Variables:
```typescript
const cardTypes = ref<CardType[]>([])
const loading = ref(false)
const showModal = ref(false)
const submitting = ref(false)
const editTarget = ref<CardType | null>(null)
const form = ref({
name: '',
typeIdx: 0, // Index into typeOptions
priceStr: '', // String, parsed to number
originalPriceStr: '',
totalTimesStr: '',
durationDaysStr: '90', // Default 90 days
sortOrderStr: '0', // Default 0
description: '',
})
```
#### Functions:
**fetchCardTypes()**
- Calls `adminStore.fetchCardTypes()`
- Sets loading state
- Updates `cardTypes` ref
**openAdd()**
- Sets `editTarget = null`
- Resets `form` to initial state
- Sets `showModal = true`
-**Opens new card form**
**openEdit(ct: CardType)**
- Sets `editTarget = ct`
- Populates `form` from card data
- Finds `typeIdx` from typeOptions
- Sets `showModal = true`
-**Opens edit form with card data**
**closeModal()**
- Sets `showModal = false`
- Clears `editTarget`
**submitForm()**
- Validates: name (required), price (required, > 0), durationDays (required, >= 1)
- Parses string inputs to numbers
- Builds payload object
- If `editTarget` exists: calls `adminStore.updateCardType()`
- Else: calls `adminStore.createCardType()`
- Shows success toast and refetches list
- Catches errors and shows error toast
**toggleActive(ct: CardType)**
- Calls `adminStore.updateCardType(ct.id, { isActive: !ct.isActive })`
- Refetches list
-**上架/下架 button action**
**confirmDelete(ct: CardType)**
- Shows confirmation modal: "删除卡种「X」此操作不可恢复。"
- If confirmed: calls `adminStore.deleteCardType(ct.id)`
- Soft deletes (sets isActive: false)
- Shows success toast
- Refetches list
#### Helper Functions:
**typeLabel(ct: CardType): string**
- Maps enum to Chinese: TIMES → '次卡', DURATION → '月卡', TRIAL → '体验卡'
**headerClass(ct: CardType): string**
- Returns CSS class for colored header banner
---
## 5. ADMIN STORE (Pinia)
**File**: `packages/app/src/stores/admin.ts`
```typescript
export const useAdminStore = defineStore('admin', () => {
// ─── Card types ───────────────────
const cardTypes = ref<CardType[]>([])
async function fetchCardTypes(): Promise<CardType[]> {
const data = await get<CardType[]>('/admin/card-types')
cardTypes.value = [...data].sort((a, b) => a.sortOrder - b.sortOrder)
return cardTypes.value
}
async function createCardType(dto: CreateCardTypeDto): Promise<CardType> {
const data = await post<CardType>('/admin/card-types', dto)
await fetchCardTypes() // Refetch to get updated list
return data
}
async function updateCardType(id: string, dto: UpdateCardTypeDto): Promise<CardType> {
const data = await put<CardType>(`/admin/card-types/${id}`, dto)
await fetchCardTypes() // Refetch to get updated list
return data
}
async function deleteCardType(id: string): Promise<void> {
await del(`/admin/card-types/${id}`)
await fetchCardTypes() // Refetch to get updated list
}
return {
cardTypes,
fetchCardTypes,
createCardType,
updateCardType,
deleteCardType,
// ... other admin functions
}
})
```
---
## 6. WORKFLOW FLOWS
### Adding a New Card Type
1. User taps "+ 新增卡种" button
2. `openAdd()` is called
- `editTarget = null`
- `form` reset to defaults
- `showModal = true`
3. Modal appears with empty form
4. User fills in form fields
5. User taps "确认" button
6. `submitForm()` validates, builds payload, calls `adminStore.createCardType(payload)`
7. Backend creates new CardType (with `isActive: true` by default)
8. Admin store refetches list
9. Page updates with new card
10. Modal closes automatically
### Editing a Card Type
1. User taps "编辑" button on a card
2. `openEdit(ct)` is called
- `editTarget = ct`
- `form` populated from card data
- `showModal = true`
3. Modal appears with prefilled form
4. User modifies fields
5. User taps "确认" button
6. `submitForm()` validates, builds payload, calls `adminStore.updateCardType(id, payload)`
7. Backend updates CardType
8. Admin store refetches list
9. Page updates with new data
10. Modal closes automatically
### Toggling Active Status (上架/下架)
1. User taps "下架" or "上架" button
2. `toggleActive(ct)` is called
- Calls `adminStore.updateCardType(ct.id, { isActive: !ct.isActive })`
3. Backend updates `isActive` field
4. Admin store refetches list
5. Page re-renders:
- If `isActive: false`: card becomes semi-transparent (opacity: 0.6)
- Status tag changes from "销售中" to "已下架"
- Button text changes
### Deleting a Card Type
1. User taps "删除" button
2. `confirmDelete(ct)` is called
- Shows confirmation dialog
3. User confirms deletion
4. `adminStore.deleteCardType(ct.id)` called
5. Backend does soft delete: sets `isActive: false`
6. Admin store refetches list
7. Page updates (card marked as inactive)
---
## 7. API COMMUNICATION
### Request Utility
**File**: `packages/app/src/utils/request.ts`
```typescript
const BASE_URL = 'http://localhost:3000/api' // or production URL
// Helper functions
async function get<T>(url: string, data?: Record<string, unknown>): Promise<T>
async function post<T>(url: string, data?: Record<string, unknown>): Promise<T>
async function put<T>(url: string, data?: Record<string, unknown>): Promise<T>
async function del<T>(url: string, data?: Record<string, unknown>): Promise<T>
```
**Response Format**:
```typescript
interface ApiResponse<T> {
success: boolean
data: T | null
message: string | null
}
```
All admin endpoints require:
- JWT Bearer token (from storage)
- User role must be ADMIN
---
## 8. PRICE HANDLING
**Important**: Prices are stored as integers (cents) in DB and API
- ¥980 is stored as `98000` cents
- Frontend displays formatted: `¥980.00`
**Formatting**:
```typescript
export function formatPrice(cents: number): string {
return (cents / 100).toFixed(2) // 98000 → "980.00"
}
```
**In Page**: `¥{{ formatPrice(ct.price) }}`
---
## 9. CARD TYPE CATEGORIES
### TIMES Card (次卡)
- Used for class count-based purchases
- Example: "10次课套餐"
- **Required fields**: `totalTimes` (e.g., 10)
- Optional fields: `originalPrice`, `description`
- Color: Dark blue gradient (`#1a1a2e` to `#2d2d5e`)
### DURATION Card (月卡)
- Used for time-period-based purchases
- Example: "30天卡"
- **Required fields**: `durationDays`
- `totalTimes` is optional/not used
- Color: Purple gradient (`#6c3483` to `#9b59b6`)
### TRIAL Card (体验卡)
- Used for trial/sample purchases
- Color: Gold/tan gradient (`#7d6608` to `#c9a87c`)
---
## 10. FIELD REQUIREMENTS & VALIDATION
| Field | Create | Update | Type | Validation |
|-------|--------|--------|------|-----------|
| name | ✓ Required | Optional | string | Trimmed, non-empty |
| type | ✓ Required | Optional | enum | TIMES \| DURATION \| TRIAL |
| totalTimes | Optional | Optional | integer | Min: 1 |
| durationDays | ✓ Required | Optional | integer | Min: 1 |
| price | ✓ Required | Optional | number | Min: 0 |
| originalPrice | Optional | Optional | number | Min: 0 |
| description | Optional | Optional | string | Max: 200 chars |
| sortOrder | Optional | Optional | integer | Min: 0, default: 0 |
| isActive | N/A | Optional | boolean | default: true on create |
---
## 11. POTENTIAL ISSUES & BUG: Edit Popup Closes Immediately
### Issue Description
When user taps "编辑" button, the edit modal popup closes immediately instead of staying open.
### Root Cause Analysis
Looking at the template structure (lines 85-195 of card-types.vue):
```vue
<view v-if="showModal" class="modal-mask" @tap.self="closeModal">
<scroll-view scroll-y class="modal">
<!-- Form content -->
</scroll-view>
</view>
```
**The problem**:
1. User taps "编辑" button on a card (line 67)
2. `openEdit(ct)` sets `showModal = true`
3. Modal appears
4. BUT: The tap event likely **bubbles** or there's a **race condition**
5. The click that triggered `openEdit()` might also trigger `closeModal()`
### Potential Causes:
1. **Event Propagation Issue**:
- The edit button tap might bubble to parent elements
- The modal-mask has `@tap.self="closeModal"`
- If the modal appears in the same frame, the tap event might close it
2. **Modal Rendering Timing**:
- If modal renders synchronously in the same event tick
- The tap event (which hasn't finished propagating) might hit the modal-mask
3. **Vue/Uni-app Quirk**:
- Some mini-program frameworks have event timing issues
- The `.self` modifier might not work as expected with rapid re-renders
### Solution Approaches:
1. **Add click guard**: Prevent tap on edit button from propagating
```vue
<view class="ct-action-btn edit-btn" @tap.stop="openEdit(ct)">
```
2. **Add delay for modal rendering**: Let Vue finish the current cycle
```typescript
function openEdit(ct: CardType) {
editTarget.value = ct
form.value = { ... }
// Delay modal show to next tick
nextTick(() => {
showModal.value = true
})
}
```
3. **Track modal state change**: Ignore tap events for a brief moment after modal opens
```typescript
const modalJustOpened = ref(false)
function openEdit(ct: CardType) {
editTarget.value = ct
form.value = { ... }
showModal.value = true
modalJustOpened.value = true
setTimeout(() => {
modalJustOpened.value = false
}, 100)
}
function closeModal() {
if (!modalJustOpened.value) {
showModal.value = false
editTarget.value = null
}
}
```
4. **Restructure modal trigger**:
- Separate the button from the modal in the DOM
- Or use a completely different event model
---
## SUMMARY OF ALL FILES REVIEWED
1. ✅ Frontend page: `packages/app/src/pages/admin/card-types.vue` (607 lines)
2. ✅ Admin store: `packages/app/src/stores/admin.ts` (198 lines)
3. ✅ Shared types: `packages/shared/src/types/card-type.ts` (39 lines)
4. ✅ Server controller: `packages/server/src/membership/membership.controller.ts` (68 lines)
5. ✅ Server service: `packages/server/src/membership/membership.service.ts` (173 lines)
6. ✅ Create DTO: `packages/server/src/membership/dto/create-card-type.dto.ts` (45 lines)
7. ✅ Update DTO: `packages/server/src/membership/dto/update-card-type.dto.ts` (49 lines)
8. ✅ Prisma schema: `packages/server/prisma/schema.prisma` (205 lines)
9. ✅ Shared enums: `packages/shared/src/enums.ts` (47 lines)
10. ✅ Format utils: `packages/app/src/utils/format.ts` (46 lines)
11. ✅ Request utils: `packages/app/src/utils/request.ts` (80 lines)
12. ✅ Membership types: `packages/shared/src/types/membership.ts` (19 lines)
13. ✅ API types: `packages/shared/src/types/api.ts` (20 lines)

View File

@@ -1,132 +0,0 @@
# Card Types Edit Modal Bug Fix
## Bug Description
When a user taps the **[编辑]** (Edit) button in the card types admin page, the edit modal opens briefly but **closes immediately** in the same event cycle. This makes it impossible to edit card types.
### Root Cause
The bug was caused by Vue event propagation/bubbling:
1. User taps edit button → `@tap="openEdit(ct)"` fires
2. `openEdit()` sets `showModal.value = true`
3. Modal is rendered and displayed
4. The tap event **bubbles up** to the parent modal-mask element
5. Modal-mask has `@tap.self="closeModal"` which immediately closes the modal
6. Result: Modal opens and closes in the same event tick
### Code Location
File: `packages/app/src/pages/admin/card-types.vue`
**Before (buggy):**
```vue
<!-- Line 67 -->
<view class="ct-action-btn edit-btn" @tap="openEdit(ct)">
<text class="ct-action-text">编辑</text>
</view>
<!-- Line 73 -->
<view class="ct-action-btn toggle-btn" @tap="toggleActive(ct)">
...
</view>
<!-- Line 77 -->
<view class="ct-action-btn delete-btn" @tap="confirmDelete(ct)">
...
</view>
<!-- Line 85 - Modal mask -->
<view v-if="showModal" class="modal-mask" @tap.self="closeModal">
...
</view>
```
## Solution Applied
Added the `.stop` modifier to all action button tap handlers to **prevent event propagation** to parent elements:
```vue
<!-- Line 67 - FIXED -->
<view class="ct-action-btn edit-btn" @tap.stop="openEdit(ct)">
<text class="ct-action-text">编辑</text>
</view>
<!-- Line 73 - FIXED -->
<view class="ct-action-btn toggle-btn" @tap.stop="toggleActive(ct)">
...
</view>
<!-- Line 77 - FIXED -->
<view class="ct-action-btn delete-btn" @tap.stop="confirmDelete(ct)">
...
</view>
```
## Why This Works
The `.stop` modifier is equivalent to calling `event.stopPropagation()`. It prevents the tap event from bubbling up the DOM tree, so:
1. User taps edit button → `@tap.stop="openEdit(ct)"` fires
2. Event propagation is **stopped** - event does NOT bubble to modal-mask
3. `openEdit()` sets `showModal.value = true`
4. Modal renders and stays open ✓
## Technical Details
### Vue Event Modifiers Used
- **`.stop`** - Calls `event.stopPropagation()` to prevent event bubbling
### Affected Operations
Three actions were fixed:
1. **Edit** (编辑) - Opens form to edit selected card type
2. **Toggle** (上架/下架) - Toggles active status (on/off shelf)
3. **Delete** (删除) - Opens confirmation dialog for deletion
## Testing Instructions
To verify the fix works:
1. Navigate to Admin → Card Types Management
2. Click the **[编辑]** button on any card
3. Verify the edit modal opens and **stays open**
4. Edit form fields and confirm the changes save correctly
5. Test the toggle button (上架/下架) - should toggle without closing modal
6. Test the delete button - should show confirmation dialog
## Code Changes Summary
| File | Line | Change | Type |
|------|------|--------|------|
| card-types.vue | 67 | `@tap="openEdit(ct)"``@tap.stop="openEdit(ct)"` | Fix |
| card-types.vue | 73 | `@tap="toggleActive(ct)"``@tap.stop="toggleActive(ct)"` | Fix |
| card-types.vue | 77 | `@tap="confirmDelete(ct)"``@tap.stop="confirmDelete(ct)"` | Fix |
Total changes: **3 lines modified**
## Impact Assessment
- **Severity**: High - Feature completely broken, users cannot edit card types
- **Risk**: Very Low - Simple modifier addition, no logic changes
- **Testing**: Quick manual test needed
- **Performance**: No impact
- **Breaking Changes**: None
- **Backward Compatibility**: Fully compatible
## Related Documentation
See the following files for comprehensive feature documentation:
- `CARD_TYPES_ANALYSIS.md` - Deep dive into the feature
- `CARD_TYPES_QUICK_REFERENCE.md` - Quick lookup guide
- `EXPLORATION_SUMMARY.md` - Full system overview
- `CARD_TYPES_INDEX.md` - Master index with all references
## Next Steps
1. ✅ Apply the fix (COMPLETED)
2. Test the feature manually
3. Verify all three action buttons work correctly
4. Consider adding automated E2E tests for card type management
5. Review other modals for similar event propagation issues

View File

@@ -1,228 +0,0 @@
╔═══════════════════════════════════════════════════════════════════════════════╗
║ CARD TYPES MANAGEMENT - COMPLETE FLOW DIAGRAM ║
╚═══════════════════════════════════════════════════════════════════════════════╝
┌─────────────────────────────────────────────────────────────────────────────┐
│ DATABASE TIER (Prisma/MySQL) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────┐ ┌──────────────────────────┐ │
│ │ CardType Model │ │ CardTypeCategory Enum │ │
│ ├─────────────────────────┤ ├──────────────────────────┤ │
│ │ id (UUID) │ │ TIMES (classes) │ │
│ │ name (String) │ │ DURATION (months) │ │
│ │ type (Enum) ───────────────┐ │ TRIAL (trial) │ │
│ │ totalTimes (Int?) │ │ └──────────────────────────┘ │
│ │ durationDays (Int) │ │ │
│ │ price (Decimal) │ └─────────────────────────────────────────┤
│ │ originalPrice (Decimal?)│ │
│ │ description (String?) │ ┌──────────────────────┐ │
│ │ isActive (Boolean) │────→│ Soft Delete Strategy │ │
│ │ sortOrder (Int) │ │ DELETE = isActive=false │
│ │ createdAt/updatedAt │ └──────────────────────┘ │
│ └─────────────────────────┘ │
│ │
│ Relationships: ← Membership (many), Order (many) │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ API TIER (NestJS Backend) - packages/server/src/membership/ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ MembershipController MembershipService │
│ ┌────────────────────────┐ ┌──────────────────────────┐ │
│ │ GET /membership/... │ │ getActiveCardTypes() │ │
│ │ GET /admin/card-types │──────────→ │ getAllCardTypes() │ │
│ │ POST /admin/... │ │ createCardType(dto) │ │
│ │ PUT /admin/.../id │────────┐ │ updateCardType(id, dto) │ │
│ │ DELETE /admin/.../id │ │ │ deleteCardType(id) │ │
│ └────────────────────────┘ │ └──────────────────────────┘ │
│ ↓ │ ↓ │
│ Validators: └→ PrismaService (DB calls) │
│ - JwtAuthGuard (token required) │
│ - RolesGuard (ADMIN role only) │
│ │
│ Request DTOs: Response Types: │
│ ┌─CreateCardTypeDto───┐ ┌──CardType────────┐ │
│ │ name ✓ │ │ id │ │
│ │ type ✓ │ │ name │ │
│ │ durationDays ✓ │ │ type │ │
│ │ price ✓ │ ─────────→ │ totalTimes │ │
│ │ totalTimes? │ │ durationDays │ │
│ │ originalPrice? │ │ price │ │
│ │ description? │ │ originalPrice │ │
│ │ sortOrder? │ │ isActive │ │
│ └─────────────────────┘ │ sortOrder │ │
│ └──────────────────┘ │
│ ┌─UpdateCardTypeDto───┐ │
│ │ (all fields optional) Includes isActive toggle! │
│ │ name? │
│ │ type? │
│ │ price? │
│ │ isActive? ──────────────────────→ 上架/下架 functionality │
│ │ ... etc ... │
│ └─────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ SHARED TYPES TIER - packages/shared/src/types/ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ TypeScript Interfaces & Enums │
│ ├── CardType (read-only interface) │
│ ├── CreateCardTypeDto │
│ ├── UpdateCardTypeDto │
│ └── CardTypeCategory Enum: TIMES | DURATION | TRIAL │
│ │
│ Shared across Frontend & Backend │
│ ✓ Type safety │
│ ✓ Request/Response validation │
│ ✓ Documentation │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ FRONTEND TIER (Vue 3 + Uni-app) - packages/app/src/ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ card-types.vue - Admin Management Page │ │
│ ├───────────────────────────────────────────────────────────────┤ │
│ │ │ │
│ │ ┌─── Toolbar ───────────────────────────┐ │ │
│ │ │ "共 X 个卡种" [ 新增卡种] │ │ │
│ │ │ │ │ │
│ │ │ ↓ tap │ │ │
│ │ │ openAdd() ────────────────────┐ │ │ │
│ │ └───────────────────────────────────┼───┘ │ │
│ │ │ │ │
│ │ ┌─── Card List ──────────────────────┼────┐ │ │
│ │ │ for each cardType: │ │ │ │
│ │ │ ┌────────────────────────────────┐ │ │ │ │
│ │ │ │ [Header band - colored by type]│ │ │ │ │
│ │ │ │ Card Name, ¥Price │ │ │ │ │
│ │ │ │ Duration, Times, Description │ │ │ │ │
│ │ │ ├────────────────────────────────┤ │ │ │ │
│ │ │ │ [编辑] [下架] [删除] │ │ │ │ │
│ │ │ │ ↓ ↓ ↓ │ │ │ │ │
│ │ │ │ open toggle delete │ │ │ │ │
│ │ │ │ Edit() Active() Confirm() │ │ │ │ │
│ │ │ └────────────────────────────────┘ │ │ │ │
│ │ └────────────────────────────────────┼────┘ │ │
│ │ │ │ │
│ │ ┌─── Modal/Popup ────────────────────┼────┐ │ │
│ │ │ @tap.self="closeModal" on mask │ │ │ │
│ │ │ v-if="showModal" │ │ │ │
│ │ │ ┌────────────────────────────────┐ │ │ │ │
│ │ │ │ 新增卡种 / 编辑卡种 │ │ │ │ │
│ │ │ ├────────────────────────────────┤ │ │ │ │
│ │ │ │ 卡种名称 [input] │ │ │ │ │
│ │ │ │ 类型 [picker] │ │ │ │ │
│ │ │ │ 现价 [digit] │ │ │ │ │
│ │ │ │ 原价 [digit] │ │ │ │ │
│ │ │ │ 次数 [number] │ │ │ │ │
│ │ │ │ 有效天数 [number] │ │ │ │ │
│ │ │ │ 排序值 [number] │ │ │ │ │
│ │ │ │ 描述 [textarea] │ │ │ │ │
│ │ │ ├────────────────────────────────┤ │ │ │ │
│ │ │ │ [取消] [确认] │ │ │ │ │
│ │ │ │ │ │ │ │ │
│ │ │ │ ↓ tap │ │ │ │ │
│ │ │ │ submitForm() ────┐ │ │ │ │ │
│ │ │ │ closeModal() │ │ │ │ │ │
│ │ │ │ editTarget = null│ │ │ │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ └───────────────────┼───────────┘ │ │ │ │
│ │ └─────────────────────┼──────────────┘ │ │ │
│ │ │ │ │ │
│ │ Reactive State: │ │ │ │
│ │ ├─ cardTypes: [] │ │ │ │
│ │ ├─ showModal: false │ │ │ │
│ │ ├─ editTarget: null │ │ │ │
│ │ ├─ form: { │ │ │ │
│ │ │ name, typeIdx, │ │ │ │
│ │ │ priceStr, ... │ │ │ │
│ │ │ } │ │ │ │
│ │ └─ submitting: false │ │ │ │
│ │ │ │ │ │
│ └───────────────────────┼───────────────────┘ │ │
│ │ │ │
│ ┌──────────────────────────────────────┐ │ │
│ │ admin.ts (Pinia Store) │ │ │
│ ├──────────────────────────────────────┤ │ │
│ │ cardTypes: CardType[] │ │ │
│ │ │ │ │
│ │ fetchCardTypes() │ │ │
│ │ ├─ GET /admin/card-types ────────────┼────────────────────┘ │
│ │ ├─ return sorted list │ │
│ │ └─ update state │ │
│ │ │ │
│ │ createCardType(dto) │ │
│ │ ├─ POST /admin/card-types ─────→ Backend │
│ │ └─ refetch list │ │
│ │ │ │
│ │ updateCardType(id, dto) ─────────┐ │ │
│ │ ├─ PUT /admin/card-types/:id │ │ │
│ │ └─ refetch list │ │ │
│ │ │ │ │
│ │ deleteCardType(id) │ │ │
│ │ ├─ DELETE /admin/card-types/:id │ │ │
│ │ └─ refetch list │ │ │
│ └──────────────────────────────────┘ │ │
│ │ │
│ utils/request.ts │ │
│ ├─ get() │ │
│ ├─ post() │ │
│ ├─ put() │ │
│ └─ del() │ │
│ All with JWT Bearer token │ │
└─────────────────────────────────────────────────────────────────────┘
╔══════════════════════════════════════════════════════════════════════════════╗
║ CRITICAL BUG: Edit Popup Closes Immediately ║
╠══════════════════════════════════════════════════════════════════════════════╣
║ ║
║ SYMPTOM: When tapping [编辑], modal appears then instantly closes ║
║ ║
║ ROOT CAUSE: Event propagation issue ║
║ 1. User taps [编辑] button ║
║ 2. openEdit() sets showModal = true ║
║ 3. Modal renders with @tap.self="closeModal" ║
║ 4. Tap event might propagate to modal-mask in same tick ║
║ 5. closeModal() fires immediately ║
║ 6. Modal closes ║
║ ║
║ SOLUTIONS: ║
║ ║
║ Option 1: Stop propagation (RECOMMENDED - SIMPLE) ║
║ @tap.stop="openEdit(ct)" <!-- Add .stop modifier --> ║
║ ║
║ Option 2: Use nextTick() for modal rendering ║
║ function openEdit(ct: CardType) { ║
║ editTarget.value = ct ║
║ form.value = { ... } ║
║ nextTick(() => { ║
║ showModal.value = true // Defer to next frame ║
║ }) ║
║ } ║
║ ║
║ Option 3: State guard with timeout ║
║ const modalJustOpened = ref(false) ║
║ ║
║ function openEdit(ct: CardType) { ║
║ editTarget.value = ct ║
║ form.value = { ... } ║
║ showModal.value = true ║
║ modalJustOpened.value = true ║
║ setTimeout(() => { modalJustOpened.value = false }, 100) ║
║ } ║
║ ║
║ function closeModal() { ║
║ if (!modalJustOpened.value) { ║
║ showModal.value = false ║
║ editTarget.value = null ║
║ } ║
║ } ║
║ ║
╚══════════════════════════════════════════════════════════════════════════════╝

View File

@@ -1,244 +0,0 @@
# 卡种管理 (Card Types Management) - Documentation Index
**Exploration Date**: April 5, 2026
**Total Files Analyzed**: 13 source files (~1,800 lines)
**Documentation Created**: 4 comprehensive guides (1,546 lines)
---
## 📖 Documentation Files
### 1. **EXPLORATION_SUMMARY.md** ⭐ START HERE
**Best for**: Quick overview of the entire system and key findings
- What was explored (13 files, 1,800 lines)
- Documentation generated
- Key findings summary
- File inventory
- Complete workflows
- Bug identification
- Next steps
- Statistics
**Read time**: 15-20 minutes
**Size**: 12 KB, 428 lines
---
### 2. **CARD_TYPES_QUICK_REFERENCE.md** 📋 FOR LOOKUP
**Best for**: Quick lookup when working on the code
- File quick links with line numbers
- Key data model (CardType entity)
- API endpoints
- DTOs & validation rules
- UI components structure
- Form fields list
- Operations guide (Add, Edit, Toggle, Delete)
- React refs & state
- Admin store methods
- **Bug explanation with 3 solutions** ⚡
- Price handling notes
- Testing checklist
- Card type categories
**Read time**: 10 minutes
**Size**: 10 KB, 342 lines
---
### 3. **CARD_TYPES_ANALYSIS.md** 📚 FOR DEEP DIVE
**Best for**: Understanding every detail of the system
**11 Sections**:
1. Project structure
2. Database schema (Prisma)
3. Shared types & DTOs
4. Server-side implementation
5. Frontend admin page
6. Admin store (Pinia)
7. Workflow flows
8. API communication
9. Price handling
10. Card type categories
11. **Detailed bug analysis** with root cause
**Read time**: 30-40 minutes
**Size**: 16 KB, 548 lines
---
### 4. **CARD_TYPES_FLOW_DIAGRAM.txt** 🎨 FOR VISUALIZATION
**Best for**: Understanding data flow and architecture visually
- Database tier diagram
- API tier diagram
- Shared types tier
- Frontend tier (page structure, store, state)
- Complete operation flows (Add, Edit, Toggle, Delete)
- **Bug analysis with solutions**
**Read time**: 20 minutes
**Size**: 24 KB, 228 lines (ASCII art)
---
## 🎯 How to Use This Documentation
### Scenario 1: "I need to understand the whole system"
1. Start with **EXPLORATION_SUMMARY.md** (overview)
2. Look at **CARD_TYPES_FLOW_DIAGRAM.txt** (visual)
3. Dive into **CARD_TYPES_ANALYSIS.md** (details)
### Scenario 2: "I need to find something specific"
→ Use **CARD_TYPES_QUICK_REFERENCE.md** (index & lookup)
### Scenario 3: "I need to fix the edit modal bug"
→ Jump to **CARD_TYPES_QUICK_REFERENCE.md** → Section "THE BUG" or
→ Read **CARD_TYPES_ANALYSIS.md** → Section 11 "Detailed bug analysis"
### Scenario 4: "I need to see how data flows"
→ Check **CARD_TYPES_FLOW_DIAGRAM.txt**
### Scenario 5: "I'm new to this project"
→ Read in order:
1. EXPLORATION_SUMMARY.md
2. CARD_TYPES_FLOW_DIAGRAM.txt
3. CARD_TYPES_QUICK_REFERENCE.md (bookmark for later)
4. CARD_TYPES_ANALYSIS.md (as needed for details)
---
## 🔍 Quick File Locations
### Frontend
- Admin page: `packages/app/src/pages/admin/card-types.vue` (607 lines)
- Pinia store: `packages/app/src/stores/admin.ts` (198 lines)
### Backend
- Controller: `packages/server/src/membership/membership.controller.ts` (68 lines)
- Service: `packages/server/src/membership/membership.service.ts` (173 lines)
- Create DTO: `packages/server/src/membership/dto/create-card-type.dto.ts` (45 lines)
- Update DTO: `packages/server/src/membership/dto/update-card-type.dto.ts` (49 lines)
### Database
- Prisma schema: `packages/server/prisma/schema.prisma` (205 lines)
### Shared Types
- Card types: `packages/shared/src/types/card-type.ts` (39 lines)
- Enums: `packages/shared/src/enums.ts` (47 lines)
---
## ⚡ The Critical Bug
**What**: Edit modal closes immediately when user taps [编辑] button
**Why**: Event propagation issue - tap event bubbles to modal-mask's @tap.self
**Where to Fix**: Line 67 of `packages/app/src/pages/admin/card-types.vue`
**Simple Fix**: Change `@tap="openEdit(ct)"` to `@tap.stop="openEdit(ct)"`
**See Also**:
- CARD_TYPES_QUICK_REFERENCE.md → "THE BUG" section
- CARD_TYPES_ANALYSIS.md → Section 11
- CARD_TYPES_FLOW_DIAGRAM.txt → Bottom (3 solutions shown)
---
## 📊 Key Statistics
| Aspect | Count |
|--------|-------|
| Source files analyzed | 13 |
| Total lines of code | ~1,800 |
| API endpoints | 5 |
| Card type categories | 3 (TIMES, DURATION, TRIAL) |
| Core operations | 4 (Create, Read, Update, Delete) |
| Documentation files | 4 |
| Documentation lines | 1,546 |
| Bugs identified | 1 |
| Bug severity | High (UX-breaking) |
---
## 🎨 Card Type Categories
1. **次卡 (TIMES)**: Class count-based (e.g., 10 classes) - Dark blue
2. **月卡 (DURATION)**: Time period-based (e.g., 30 days) - Purple
3. **体验卡 (TRIAL)**: Trial cards - Gold/tan
---
## 🔐 Auth & Security
- Admin endpoints require JWT Bearer token
- Admin endpoints require ADMIN role
- Public endpoint (GET /membership/card-types) returns only active cards
---
## 💾 Database Details
**CardType Model**:
- Soft delete (set isActive=false, not removed from DB)
- Relationships: Membership (many), Order (many)
- Indexed on: isActive, sortOrder
---
## 📝 API Endpoints
| Method | Endpoint | Auth | Purpose |
|--------|----------|------|---------|
| GET | /membership/card-types | None | Get active cards (public) |
| GET | /admin/card-types | JWT+Admin | Get all cards (admin) |
| POST | /admin/card-types | JWT+Admin | Create card |
| PUT | /admin/card-types/:id | JWT+Admin | Update card (can toggle isActive) |
| DELETE | /admin/card-types/:id | JWT+Admin | Soft delete card |
---
## 🧪 Testing Checklist
- [ ] Create new card with all types
- [ ] Edit existing card
- [ ] Toggle card status (上架/下架)
- [ ] Delete card (soft delete works)
- [ ] List updates after each operation
- [ ] Modal closes after submit
- [ ] **FIX**: Edit modal stays open (not closes immediately)
---
## 🚀 Next Steps
1. **Quick start**: Read EXPLORATION_SUMMARY.md (15 min)
2. **Deep dive**: Read CARD_TYPES_ANALYSIS.md (30 min)
3. **Reference**: Bookmark CARD_TYPES_QUICK_REFERENCE.md
4. **Implement bug fix** (5 min)
5. **Test thoroughly** (15 min)
---
## 💡 Price Handling
**Critical**: Prices are stored as integers (cents)
- ¥980 = 98000 cents
- Display: formatPrice(98000) = "980.00"
---
## 📚 Related Documentation
- `ADMIN_SCHEDULING_EXPLORATION.md` - Scheduling feature
- `BOOKING_ARCHITECTURE_DIAGRAM.md` - Booking system
- `BOOKING_PAGE_ANALYSIS.md` - Booking pages
- `SCHEDULING_QUICK_REFERENCE.md` - Scheduling reference
---
**Generated**: 2026-04-05
**Ready to**: Implement features, fix bugs, deploy updates

View File

@@ -1,342 +0,0 @@
# Card Types Management - Quick Reference Guide
## 📁 File Quick Links
| Purpose | File Path | Lines |
|---------|-----------|-------|
| **Frontend** | | |
| Admin page | `packages/app/src/pages/admin/card-types.vue` | 607 |
| Store (Pinia) | `packages/app/src/stores/admin.ts` | 198 |
| Request utils | `packages/app/src/utils/request.ts` | 80 |
| Format utils | `packages/app/src/utils/format.ts` | 46 |
| **Backend** | | |
| Controller | `packages/server/src/membership/membership.controller.ts` | 68 |
| Service | `packages/server/src/membership/membership.service.ts` | 173 |
| Create DTO | `packages/server/src/membership/dto/create-card-type.dto.ts` | 45 |
| Update DTO | `packages/server/src/membership/dto/update-card-type.dto.ts` | 49 |
| **Database** | | |
| Prisma schema | `packages/server/prisma/schema.prisma` | 205 |
| **Shared** | | |
| Card types | `packages/shared/src/types/card-type.ts` | 39 |
| Enums | `packages/shared/src/enums.ts` | 47 |
| API types | `packages/shared/src/types/api.ts` | 20 |
| Membership types | `packages/shared/src/types/membership.ts` | 19 |
---
## 🎯 Key Data Model
### CardType Entity
```typescript
{
id: string (UUID)
name: string // e.g., "10次课套餐"
type: 'TIMES' | 'DURATION' | 'TRIAL'
totalTimes: number | null // For TIMES/TRIAL cards
durationDays: number // How many days valid
price: number (cents) // ¥980 = 98000
originalPrice: number | null // Strikethrough price
description: string | null
isActive: boolean // 上架(true) / 下架(false)
sortOrder: number // Display order (ascending)
createdAt: DateTime
updatedAt: DateTime
}
```
---
## 🔄 API Endpoints
### Public (No Auth)
```
GET /membership/card-types Returns active cards only
```
### Admin Only (JWT + ADMIN Role)
```
GET /admin/card-types Get all cards (including inactive)
POST /admin/card-types Create new card
PUT /admin/card-types/:id Update card (can toggle isActive)
DELETE /admin/card-types/:id Soft delete (sets isActive=false)
```
---
## 📝 DTOs & Validation
### CreateCardTypeDto
| Field | Required | Type | Validation |
|-------|----------|------|-----------|
| name | ✓ | string | Must be non-empty |
| type | ✓ | enum | TIMES \| DURATION \| TRIAL |
| durationDays | ✓ | int | Min: 1 |
| price | ✓ | number | Min: 0 |
| totalTimes | - | int | Min: 1 (optional) |
| originalPrice | - | number | Min: 0 (optional) |
| description | - | string | Max: 200 (optional) |
| sortOrder | - | int | Min: 0 (optional, default: 0) |
### UpdateCardTypeDto
- All fields optional (partial update)
- Can toggle `isActive` for 上架/下架
---
## 🎨 UI Components
### Page Structure
```
┌─────────────────────────────────────────┐
│ Toolbar: "共 X 个卡种" [ 新增卡种] │
├─────────────────────────────────────────┤
│ Card List │
│ ┌───────────────────────────────────────┐ │
│ │ [Colored Header Band] │ │
│ │ Card Name, ¥Price, Duration, etc │ │
│ │ [编辑] [上架/下架] [删除] │ │
│ └───────────────────────────────────────┘ │
│ ... more cards ... │
├─────────────────────────────────────────┤
│ Modal (Add/Edit Form) │
│ - Title: 新增卡种 / 编辑卡种 │
│ - Input fields │
│ - [取消] [确认] buttons │
└─────────────────────────────────────────┘
```
### Header Colors by Type
- **次卡 (TIMES)**: Dark blue `linear-gradient(90deg, #1a1a2e, #2d2d5e)`
- **月卡 (DURATION)**: Purple `linear-gradient(90deg, #6c3483, #9b59b6)`
- **体验卡 (TRIAL)**: Gold/tan `linear-gradient(90deg, #7d6608, #c9a87c)`
---
## 📋 Form Fields in Modal
```
卡种名称 text input
类型 picker (次卡, 月卡, 体验卡)
现价(元) digit input
原价(元) digit input (optional)
次数 number input (optional)
有效天数 number input (required, default: 90)
排序值 number input (default: 0)
描述 textarea (optional)
```
---
## 🔄 Operations
### ADD New Card Type
1. Tap [ 新增卡种]
2. Modal opens with empty form
3. Fill fields (name, type, price, duration required)
4. Tap [确认]
5. Backend creates card (isActive=true by default)
6. Modal closes, list updates
### EDIT Card Type
1. Tap [编辑] on a card
2. Modal opens with prefilled form
3. Modify desired fields
4. Tap [确认]
5. Backend updates card
6. Modal closes, list updates
### TOGGLE Status (上架/下架)
1. Tap [上架] or [下架]
2. Backend updates `isActive` toggle
3. List re-renders
- Card becomes transparent if `isActive=false`
- Status tag and button text change
### DELETE Card Type
1. Tap [删除]
2. Confirmation dialog appears
3. If confirmed: backend soft-deletes (isActive=false)
4. List updates
---
## ⚙️ React Refs & State
```typescript
const cardTypes = ref<CardType[]>([]) // Current list
const loading = ref(false) // Loading spinner
const showModal = ref(false) // Modal visibility
const submitting = ref(false) // Form submission state
const editTarget = ref<CardType | null>(null) // Card being edited (null=add)
const form = ref({
name: '',
typeIdx: 0, // Index into typeOptions array
priceStr: '', // String (parsed to number on submit)
originalPriceStr: '',
totalTimesStr: '',
durationDaysStr: '90', // Default 90 days
sortOrderStr: '0', // Default 0
description: '',
})
```
---
## 💾 Admin Store Methods
```typescript
// Fetch all cards (including inactive)
await adminStore.fetchCardTypes(): Promise<CardType[]>
// Create new card
await adminStore.createCardType(dto: CreateCardTypeDto): Promise<CardType>
// Update card (all fields optional)
// Can toggle isActive, change price, name, etc.
await adminStore.updateCardType(id: string, dto: UpdateCardTypeDto): Promise<CardType>
// Delete card (soft delete: sets isActive=false)
await adminStore.deleteCardType(id: string): Promise<void>
```
**Note**: All mutations refetch the list automatically
---
## 🐛 THE BUG: Edit Modal Closes Immediately
### Symptom
When user taps [编辑], the edit modal opens then immediately closes.
### Root Cause
Event propagation issue:
1. User taps [编辑] button
2. `openEdit()` runs and sets `showModal = true`
3. Modal renders in same event tick
4. Tap event propagates to `modal-mask` which has `@tap.self="closeModal"`
5. Modal closes instantly
### Current Code (Buggy)
```vue
<view class="ct-action-btn edit-btn" @tap="openEdit(ct)">
<text class="ct-action-text">编辑</text>
</view>
<!-- Modal -->
<view v-if="showModal" class="modal-mask" @tap.self="closeModal">
<!-- ... form ... -->
</view>
```
### Solutions (Pick One)
**Option 1: Stop Propagation (RECOMMENDED)**
```vue
<view class="ct-action-btn edit-btn" @tap.stop="openEdit(ct)">
<!-- Add .stop modifier to prevent bubbling -->
</view>
```
**Option 2: Use nextTick()**
```typescript
import { nextTick } from 'vue'
function openEdit(ct: CardType) {
editTarget.value = ct
form.value = { ... populate ... }
nextTick(() => {
showModal.value = true // Render in next frame
})
}
```
**Option 3: Guard with State**
```typescript
const modalJustOpened = ref(false)
function openEdit(ct: CardType) {
editTarget.value = ct
form.value = { ... }
showModal.value = true
modalJustOpened.value = true
setTimeout(() => { modalJustOpened.value = false }, 100)
}
function closeModal() {
if (!modalJustOpened.value) { // Ignore if just opened
showModal.value = false
editTarget.value = null
}
}
```
**Recommendation**: Use **Option 1** (@tap.stop) - it's simplest and most idiomatic.
---
## 💡 Price Handling
**Important**: Prices are stored as **integers (cents)** in DB and API
- Frontend sends: `{ price: 98000 }` for ¥980
- Display: `formatPrice(98000)``"980.00"`
```typescript
// Utility function
export function formatPrice(cents: number): string {
return (cents / 100).toFixed(2)
}
// Usage in template
¥{{ formatPrice(ct.price) }}
```
---
## 🧪 Testing Checklist
- [ ] Can create new card with all field types
- [ ] Can edit existing card and see changes
- [ ] Can toggle card status (上架/下架)
- [ ] Card becomes transparent when inactive
- [ ] Can delete card (shows confirmation)
- [ ] List updates after each operation
- [ ] Price displayed with 2 decimal places
- [ ] Modal closes after successful submit
- [ ] Modal can be closed by tapping outside (on mask)
- [ ] Modal can be closed by tapping Cancel button
- [ ] **BUG FIX**: Edit modal stays open and doesn't close immediately
---
## 📊 Card Type Categories
| Type | Chinese | Use Case | Example | Color | Required Fields |
|------|---------|----------|---------|-------|-----------------|
| TIMES | 次卡 | Classes count | 10次课套餐 | Dark blue | totalTimes |
| DURATION | 月卡 | Time period | 30天卡 | Purple | durationDays |
| TRIAL | 体验卡 | Trial | 体验卡 | Gold/tan | durationDays |
---
## 🔗 Related Features
### Memberships (User Side)
- User can purchase cards (creates Order)
- Payment successful creates Membership record
- Membership tracks remaining times or expiry date
- Used when user books a class
### Public Card Display
- Users see only `isActive=true` cards on shop page
- Sorted by `sortOrder`
- Can purchase cards
---
## 📚 Documentation Files
- `CARD_TYPES_ANALYSIS.md` - Complete technical analysis
- `CARD_TYPES_FLOW_DIAGRAM.txt` - Visual flow diagrams
- `CARD_TYPES_QUICK_REFERENCE.md` - This file (quick lookup)

150
CLAUDE.md
View File

@@ -1,150 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目概述
普拉提工作室预约与会员管理的微信小程序。TypeScript monorepo包含三个包
- **packages/server** — NestJS 后端REST API、Prisma ORM、PostgreSQL
- **packages/app** — Vue 3 + Pinia 前端,基于 Uni-app目标平台 mp-weixin
- **packages/shared** — 前后端共用的 TypeScript 类型、枚举和常量
## 常用命令
### 开发
```bash
pnpm dev:server # NestJS watch 模式 (localhost:3000)
pnpm dev:app # 微信小程序开发服务器
pnpm build:shared # 必须先构建 shared再构建 server/app
```
### 测试(仅 server
```bash
cd packages/server
pnpm test # 运行全部测试
pnpm test -- auth.service.spec # 运行单个测试文件
pnpm test:watch # watch 模式
pnpm test:cov # 覆盖率报告
```
Jest 配置内联在 `packages/server/package.json`。测试文件位于 `__tests__/` 子目录(如 `src/auth/__tests__/auth.service.spec.ts`),匹配模式:`*.spec.ts`
### 数据库
```bash
cd packages/server
pnpm prisma:generate # schema 变更后重新生成 Prisma Client
pnpm prisma:migrate # 运行迁移(交互式)
pnpm prisma:seed # 填充种子数据
```
### 代码检查
```bash
pnpm lint # 所有包的 ESLint 检查
```
## 架构
### 数据流
```
微信小程序 → Uni-app (Vue 3) → REST API (NestJS) → Prisma → PostgreSQL
↕ ↕
Pinia stores @nestjs/schedule (定时任务)
```
### 后端模块结构
每个功能是一个 NestJS 模块,遵循 controller → service → Prisma 模式。核心模块:
- **auth** — 微信 OAuth 登录code2Session、JWT 令牌、手机号绑定
- **booking** — 创建/取消预约,含会员卡验证和容量检查
- **time-slot** — 课程时段管理;`SlotGeneratorService` 根据 `WeekTemplate` 自动生成
- **membership** — 基于卡的会员制TIMES 次卡、DURATION 时效卡、TRIAL 体验卡)
- **payment** — 微信支付集成,用于购卡
- **scheduler** — 定时任务02:00 自动生成时段02:30 清理过期时段
### 前端结构
- **pages/** — 按路由组织的页面home、booking、card、profile、admin
- **stores/** — Pinia 状态管理user、booking、studio、admin
- **utils/request.ts** — 封装 `uni.request` 的 HTTP 客户端,自动携带 JWT
- **utils/auth.ts** — 微信登录流程uni.login → 服务端 /auth/login → 存储 token
### Shared 包
所有 API 类型、DTO、枚举和业务常量定义在 `packages/shared/src/`,前后端通过 `@mp-pilates/shared` 引用。路径别名配置在 `tsconfig.base.json` 和 Jest 的 `moduleNameMapper` 中。
### 数据库 Schema
Prisma schema 位于 `packages/server/prisma/schema.prisma`,关键约定:
- Model 用 PascalCase表名用 snake_case`@@map`
- 字段用 camelCase列名用 snake_case`@map`
- 所有 ID 为 UUID
- 金额字段使用 `Decimal(10, 0)`
- 关键唯一约束:`TimeSlot``@@unique([date, startTime, endTime])``Booking``@@unique([userId, timeSlotId])`
### 核心业务规则
- 预约需要有效的会员卡(剩余次数或有效期内)
- 取消预约需在课程开始前 `cancelHoursLimit` 小时(默认 2 小时,可在 StudioConfig 中配置)
- 时段根据 WeekTemplate 自动生成未来 14 天的课程
- 默认时段容量为 1私教课
## 环境配置
需要 Node 20+.nvmrc、pnpm 8+、PostgreSQL。复制 `packages/server/.env.example``.env.local`,需配置 DATABASE_URL、JWT_SECRET 及微信相关凭证APPID、SECRET、MCH_ID、MCH_KEY、证书路径
## 开发约定
- **API 前缀**:所有路由在 `/api`setGlobalPrefix
- **参数校验**:全局 ValidationPipe启用 whitelist + forbidNonWhitelisted + transform
- **鉴权守卫**:受保护路由使用 `@UseGuards(JwtAuthGuard)`,通过 `@Req()` 从 JWT 载荷提取用户
- **角色**MEMBER 和 ADMIN管理员路由使用自定义角色守卫
- **异常处理**:使用 NestJS 内置异常BadRequestException、NotFoundException 等)
- **分页**:统一使用 `PaginatedResponse<T>`,包含 data、total、page、limit
- **pnpm**:使用 `shamefully-hoist=true`.npmrc为 Uni-app 兼容所需
## 前端样式规范
### 主题色变量(必用)
所有色值必须使用 `packages/app/src/uni.scss` 中定义的 SCSS 变量,禁止在 Vue/Scss 文件中硬编码色值。
**主题色系:**
```scss
$primary-color: #a9bfcc; /* 主色-柔雾蓝灰 */
$primary-dark: #7ba5be; /* 主色-深蓝灰 */
$primary-light: #c8d8e4; /* 主色-浅蓝灰 */
$primary-bg: #f0f6f9; /* 页面背景-冷白蓝 */
$primary-border: #d8eaf4; /* 边框-淡蓝灰 */
$primary-selected-bg: #EFF6F9; /* 选中态背景 */
```
**通用语义变量(已同步主题色):**
| 变量 | 值 | 用途 |
|------|----|------|
| `$accent-color` | `#7ba5be` | 强调色 |
| `$warning-color` | `#e8a87c` | 警告色 |
| `$brand-light` | `#c8d8e4` | 品牌浅色 |
| `$border-color` | `rgba(180,160,130,0.2)` | 边框(中性) |
| `$text-primary` | `#4A4035` | 主文字(深棕灰) |
| `$text-secondary` | `#7A6A5A` | 次文字 |
| `$text-hint` | `#A09080` | 弱提示文字 |
### 变量替换规则
| 旧硬编码 | 替换为 |
|---------|--------|
| `#c9a87c`(旧暖棕金) | `$primary-dark` |
| `#d4b896`(旧浅棕金) | `$primary-color` |
| `#C4956A`(旧警告橙棕) | `$warning-color` |
| `#B08050`(旧深棕) | `$accent-color` |
| `#7d6608`(旧深暖绿) | `#5a7a8a`(冷青灰) |
| `#e8c88a``#b49868`(旧暖渐变) | `$primary-color` / `$primary-dark` |
### CSS 变量规范
组件内部的多处共用颜色(如阴影、遮罩)若无法用 SCSS 变量,需用 `rgba($primary-dark, 0.x)` 形式动态构造,不可直接写死十六进制值。
### 新增页面/组件
新增页面或组件时:
1. 优先查阅 `uni.scss` 已有变量
2. 若需要新增语义化变量,先更新 `uni.scss`,再在组件中引用
3. 禁止在 `<style>` 块内直接写十六进制颜色值(背景色、文字色、边框、阴影均需走变量)

View File

@@ -1,359 +0,0 @@
# Component & Data Flow Hierarchy
## 🏗️ Component Tree
```
pages/booking/index.vue (Main Page)
├── DateSelector.vue
│ └── Emits: @select (date string)
│ Props: v-model (current date)
├── TimePeriodFilter.vue
│ └── Emits: @change (period key)
│ Props: v-model (current period)
├── SlotCard.vue (Multiple, v-for)
│ ├── Props: slot (TimeSlotWithBookingStatus)
│ ├── Emits: @book (slot) / @cancel (slot)
│ └── Computed: capacityLabel, capacityClass
└── BookingConfirmPopup.vue (Modal)
├── Props: visible, slot, memberships
├── Emits: @confirm ({timeSlotId, membershipId})
├── Emits: @cancel
└── State: selectedMembershipId
```
---
## 🔄 State Management Flow
```
Pinia Store (stores/booking.ts)
├── State:
│ ├── slots: TimeSlotWithBookingStatus[]
│ ├── myBookings: BookingWithDetails[]
│ ├── upcomingBookings: BookingWithDetails[]
│ ├── loadingSlots: boolean
│ └── loadingBookings: boolean
└── Actions:
├── fetchSlots(date) → GET /time-slot/available?date=
├── createBooking({...}) → POST /booking
├── cancelBooking(bookingId) → PUT /booking/:id/cancel
├── fetchMyBookings(status?) → GET /booking/my
└── fetchUpcomingBookings() → GET /booking/my/upcoming
Pinia Store (stores/user.ts)
├── State:
│ ├── user: UserProfileResponse | null
│ ├── memberships: MembershipWithCardType[]
│ ├── token: string
│ └── stats: UserStatsResponse | null
├── Computed:
│ ├── loggedIn: boolean
│ ├── hasValidMembership: boolean
│ └── activeMemberships: MembershipWithCardType[]
└── Actions:
├── login() → WX login + token
├── fetchMemberships() → GET /membership/my
├── fetchProfile() → GET /user/profile
└── logout()
```
---
## 📡 API Calls Sequence
```
INITIAL LOAD
├─ POST /auth/wxLogin
│ └─ Returns: { token, user }
├─ GET /membership/my (if logged in)
│ └─ Returns: MembershipWithCardType[]
└─ GET /time-slot/available?date=TODAY
└─ Returns: TimeSlotWithBookingStatus[]
DATE CHANGE
└─ GET /time-slot/available?date=SELECTED_DATE
└─ Returns: TimeSlotWithBookingStatus[]
BOOKING CREATION
├─ POST /booking
│ ├─ Body: { timeSlotId, membershipId }
│ └─ Returns: BookingWithDetails
└─ GET /time-slot/available?date=SELECTED_DATE (refresh)
└─ Returns: Updated slots with isBookedByMe: true
BOOKING CANCELLATION
├─ PUT /booking/:bookingId/cancel
│ └─ Returns: Updated BookingWithDetails
└─ GET /time-slot/available?date=SELECTED_DATE (refresh)
└─ Returns: Updated slots with isBookedByMe: false
```
---
## 🎭 Slot Card State Machine
```
TimeSlotWithBookingStatus {
status: 'OPEN' | 'FULL' | 'CLOSED'
isBookedByMe: boolean
}
STATE COMBINATIONS:
┌─────────────────────────────────────┐
│ status: OPEN, isBookedByMe: false │
├─────────────────────────────────────┤
│ Button: "可预约" (Tan) │
│ Color: #c9a87c │
│ Action: onBookTap() → Popup │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ status: OPEN, isBookedByMe: true │
├─────────────────────────────────────┤
│ Badge: "已预约" │
│ Link: "取消" (Red underline) │
│ Indicator: Tan bar on left │
│ Action: onCancelTap() → Confirm │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ status: FULL │
├─────────────────────────────────────┤
│ Button: "已约满" (Gray) │
│ Color: #f0f0f0 │
│ Action: Disabled (no-op) │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ status: CLOSED │
├─────────────────────────────────────┤
│ Button: "已关闭" (Gray) │
│ Color: #f0f0f0 │
│ Action: Disabled (no-op) │
└─────────────────────────────────────┘
```
---
## 📊 Capacity Label Colors
```
Condition Label Background Text
─────────────────────────────────────────────────────────────────
status === CLOSED "已关闭" #f5f5f5 #999
status === FULL "0/1 人" #fef0f0 #ef4444
bookedCount >= 80% "0/1 人" #fff8ed #f59e0b
bookedCount < 80% "0/1 人" #f0faf3 #4caf50
```
---
## 🌐 Time Period Filters
```
Key Label Start End Range
──────────────────────────────────────────────────────
null (all) "全部" - - All times
'MORNING' "上午" 06:00 12:00 6am-12pm
'AFTERNOON' "下午" 12:00 18:00 12pm-6pm
'EVENING' "晚上" 18:00 22:00 6pm-10pm
Filtering Logic:
slot.startTime >= period.start && slot.startTime < period.end
```
---
## 📱 UI Layout Breakdown
```
┌─────────────────────────────────┐
│ 📱 Booking Page (750rpx) │
├─────────────────────────────────┤
│ │
│ ┌─────────────────────────────┐│
│ │ 🎫 STICKY HEADER (z-index:100)
│ │ ┌───────────────────────────┐│
│ │ │ DateSelector (horizontal) ││
│ │ │ 今天 5月 4月 3月... ││
│ │ └───────────────────────────┘│
│ │ ┌───────────────────────────┐│
│ │ │ TimePeriodFilter (tabs) ││
│ │ │ 全部 | 上午 | 下午 | 晚上││
│ │ └───────────────────────────┘│
│ └─────────────────────────────┘│
│ │
│ ┌─────────────────────────────┐│
│ │ 📜 SCROLL AREA ││
│ │ ││
│ │ OR [Loading skeleton] ×4 ││
│ │ OR [Empty state] ││
│ │ ││
│ │ [SlotCard 1] ┌──────────┐ ││
│ │ 09:00-10:00 │ 0/1 人 │ ││
│ │ │ [可预约] │ ││
│ │ ┌──────────┘ └─────────┘ ││
│ │ [SlotCard 2] ┌──────────┐ ││
│ │ 10:00-11:00 │ 1/1 人 │ ││
│ │ ✓已预约 [取消]└─────────┘ ││
│ │ [SlotCard 3] ... ││
│ │ ││
│ │ [Spacer 48rpx] ││
│ └─────────────────────────────┘│
│ │
│ ┌──────────────────────────────┐│
│ │ [BookingConfirmPopup] (Modal)││
│ │ ┌────────────────────────────┐│
│ │ │ ✕ 确认预约 ││
│ │ │ ││
│ │ │ 日期: 2026-04-05 ││
│ │ │ 时间: 09:00 - 10:00 ││
│ │ │ 剩余: 1 个名额 ││
│ │ │ ───────────────────── ││
│ │ │ 💳 私教课程 ││
│ │ │ 剩余 10 次 ✓ ││
│ │ │ 确认后扣除 1 次课时 ││
│ │ │ ││
│ │ │ [取消] [确认预约] ││
│ │ └────────────────────────────┘│
│ └──────────────────────────────┘│
└─────────────────────────────────┘
```
---
## 🔐 Authentication Flow
```
PAGE LOAD
├─ Check: userStore.loggedIn?
├─ YES
│ ├─ Check: userStore.activeMemberships.length > 0?
│ │ ├─ NO: await fetchMemberships()
│ │ └─ YES: (already loaded)
│ │
│ └─ Load today's slots
└─ NO (not logged in)
└─ Page loads but booking disabled
(onBookTap shows login modal)
USER TAPS "可预约"
├─ Check: userStore.loggedIn?
│ ├─ NO: Show login modal
│ │ ├─ User confirms → wxLogin()
│ │ ├─ Retry booking flow
│ │ └─ Success: Load memberships, show popup
│ │
│ └─ YES: Continue
├─ Check: userStore.hasValidMembership?
│ ├─ NO: Show purchase modal
│ │ └─ User navigates to /pages/store/index
│ │
│ └─ YES: Continue
└─ Show BookingConfirmPopup
```
---
## ⚙️ Error Handling (Current)
```
fetchSlots() Error:
├─ console.error('Fetch slots failed:', err)
├─ slots.value = []
└─ UI shows: "当日暂无可约时段" (empty state)
❌ User can't distinguish network error from no slots
createBooking() Error:
├─ uni.showToast({ title: message, icon: 'none' })
└─ UI shows: Error toast (Good ✓)
cancelBooking() Error:
├─ uni.showToast({ title: message, icon: 'none' })
└─ UI shows: Error toast (Good ✓)
```
---
## 🧮 Computed Values & Reactivity
```
PAGE LEVEL:
scrollHeight = computed(() => {
// Recalc when window size changes
// = windowHeight - headerHeight - tabbarHeight
})
filteredSlots = computed(() => {
// Depends on: slots, selectedPeriod
// Recalc when either changes
// Filters by TIME_PERIODS[selectedPeriod].start/end
})
COMPONENT LEVEL:
SlotCard.capacityLabel = computed(() => {
// Depends on: slot.status, slot.bookedCount, slot.capacity
// Returns: "已关闭" | "X/Y 人"
})
SlotCard.capacityClass = computed(() => {
// Depends on: slot.status, slot.bookedCount, slot.capacity
// Returns: "cap-open" | "cap-almost" | "cap-full" | "cap-closed"
})
BookingConfirmPopup.selectedMembership = computed(() => {
// Depends on: selectedMembershipId, memberships
// Returns: Found membership or null
})
```
---
## 🎯 Key Data Transformations
```
Raw API Response
└─ TimeSlot {
date: "2026-04-05",
startTime: "09:00",
endTime: "10:00",
...
}
STORE (bookingStore.slots)
└─ TimeSlotWithBookingStatus extends TimeSlot {
isBookedByMe: boolean,
myBookingId: string | null
}
DISPLAY (SlotCard)
├─ capacityLabel: "0/1 人" | "已关闭"
├─ capacityClass: "cap-open" | "cap-almost" | "cap-full" | "cap-closed"
├─ Button state: "可预约" | "已预约" | "已约满" | "已关闭"
└─ Time display: "09:00 - 10:00" (slice first 5 chars)
BOOKING CREATION
├─ Selected Slot ID
├─ Selected Membership ID
└─ POST /booking
└─ Success: Slot updated with isBookedByMe: true
```

View File

@@ -1,428 +0,0 @@
# 卡种管理 (Card Types Management) - Complete Exploration Summary
**Date**: 2026-04-05
**Project**: MP-Pilates (WeChat Mini-Program for Pilates Studio Booking)
**Focus**: Card types (卡种) admin feature
---
## 📦 What Was Explored
A comprehensive exploration of the **card types management system** across all three tiers of the application:
- Frontend (Vue 3 + Uni-app)
- Backend (NestJS)
- Database (Prisma/MySQL)
- Shared Types
### Total Files Analyzed: **13 files, ~1,800 lines of code**
---
## 📚 Documentation Generated
Three comprehensive documentation files have been created in the project root:
### 1. **CARD_TYPES_ANALYSIS.md** (Complete Technical Guide)
- **Sections**: 11 major sections
- **Content**:
- Database schema details
- Shared types and DTOs
- Server-side implementation (controller, service, DTOs)
- Frontend admin page structure
- Admin store (Pinia) implementation
- Complete workflow flows
- API communication details
- Price handling
- Card type categories
- Field requirements & validation table
- **Detailed bug analysis**: Edit popup closes immediately
### 2. **CARD_TYPES_FLOW_DIAGRAM.txt** (Visual Architecture)
- **Content**:
- Database tier diagram (CardType model, enums, soft delete)
- API tier diagram (endpoints, validators, DTOs)
- Shared types tier
- Frontend tier (page structure, store, components)
- Complete operation flows (Add, Edit, Toggle, Delete)
- **Bug analysis with solutions** (3 solution options)
### 3. **CARD_TYPES_QUICK_REFERENCE.md** (Quick Lookup)
- **Sections**: 13 quick-reference sections
- **Content**:
- File quick links with line numbers
- Key data model
- API endpoints
- DTOs & validation rules
- UI components
- Form fields
- Operations guide
- React refs & state
- Admin store methods
- Bug explanation and solutions
- Price handling notes
- Testing checklist
- Card type categories
---
## 🎯 Key Findings
### Data Structure
```
CardType
├── id (UUID)
├── name (卡种名称)
├── type (TIMES | DURATION | TRIAL)
├── totalTimes (次卡的次数)
├── durationDays (有效天数)
├── price (现价,单位:分)
├── originalPrice (原价,可选)
├── description (描述)
├── isActive (上架状态)
├── sortOrder (显示顺序)
└── timestamps
```
### Three Card Type Categories
1. **次卡 (TIMES)**: Class count-based (e.g., 10 classes)
2. **月卡 (DURATION)**: Time period-based (e.g., 30 days)
3. **体验卡 (TRIAL)**: Trial cards
### Core Operations
-**Create**: Add new card types
-**Read**: View all cards (admin) or active cards (public)
-**Update**: Edit card details or toggle status
-**Delete**: Soft delete (sets isActive=false)
### API Endpoints
```
GET /membership/card-types (public)
GET /admin/card-types (admin only)
POST /admin/card-types (admin only)
PUT /admin/card-types/:id (admin only)
DELETE /admin/card-types/:id (admin only)
```
---
## 🐛 Critical Bug Identified
### **Edit Modal Closes Immediately on Tap**
**Symptom**: When user taps the [编辑] button, the edit form modal appears and then instantly closes.
**Root Cause**: Event propagation issue
- User taps [编辑] button
- `openEdit()` sets `showModal = true`
- Modal renders in the same event tick
- Tap event propagates to `modal-mask` element
- `@tap.self="closeModal"` fires immediately
- Modal closes
**Current Code (Buggy)**:
```vue
<view class="ct-action-btn edit-btn" @tap="openEdit(ct)">
<text>编辑</text>
</view>
<view v-if="showModal" class="modal-mask" @tap.self="closeModal">
<!-- form -->
</view>
```
**Recommended Fix (Option 1 - Simplest)**:
```vue
<view class="ct-action-btn edit-btn" @tap.stop="openEdit(ct)">
<!-- Add .stop modifier to stop event propagation -->
</view>
```
**Alternative Fixes**: See CARD_TYPES_QUICK_REFERENCE.md for 2 additional solutions using nextTick() or state guards.
---
## 📂 File Inventory
### Frontend Files
| File | Purpose | Lines |
|------|---------|-------|
| `packages/app/src/pages/admin/card-types.vue` | Admin page (ADD, EDIT, DELETE, TOGGLE) | 607 |
| `packages/app/src/stores/admin.ts` | Pinia store (state + API calls) | 198 |
| `packages/app/src/utils/request.ts` | HTTP request utilities | 80 |
| `packages/app/src/utils/format.ts` | Price & date formatting | 46 |
### Backend Files
| File | Purpose | Lines |
|------|---------|-------|
| `packages/server/src/membership/membership.controller.ts` | API endpoints | 68 |
| `packages/server/src/membership/membership.service.ts` | Business logic | 173 |
| `packages/server/src/membership/dto/create-card-type.dto.ts` | Create validation | 45 |
| `packages/server/src/membership/dto/update-card-type.dto.ts` | Update validation | 49 |
### Database Files
| File | Purpose | Lines |
|------|---------|-------|
| `packages/server/prisma/schema.prisma` | DB schema definition | 205 |
### Shared/Types Files
| File | Purpose | Lines |
|------|---------|-------|
| `packages/shared/src/types/card-type.ts` | CardType, CreateCardTypeDto, UpdateCardTypeDto | 39 |
| `packages/shared/src/enums.ts` | CardTypeCategory enum | 47 |
| `packages/shared/src/types/api.ts` | API response types | 20 |
| `packages/shared/src/types/membership.ts` | Membership types | 19 |
**Total**: 13 files, ~1,800 lines analyzed
---
## 🔄 Complete Workflow
### Adding a New Card Type
```
User → [ 新增卡种] → openAdd()
Modal appears with empty form
User fills: name, type, price, durationDays
[确认] → submitForm()
Validate inputs → Build payload → adminStore.createCardType()
POST /admin/card-types → Backend creates card
Refetch list → Modal closes → Page updates
```
### Editing a Card Type
```
User → [编辑] on card → openEdit(card)
Modal appears with card data
User edifies fields
[确认] → submitForm()
Validate inputs → Build payload → adminStore.updateCardType(id, payload)
PUT /admin/card-types/:id → Backend updates card
Refetch list → Modal closes → Page updates
```
### Toggling Status (上架/下架)
```
User → [上架/下架] button → toggleActive(card)
adminStore.updateCardType(id, { isActive: !current })
PUT /admin/card-types/:id → Backend toggles isActive
Refetch list → Card UI updates (opacity, status tag, button text)
```
### Deleting a Card Type
```
User → [删除] button → confirmDelete(card)
Confirmation dialog appears
User confirms
adminStore.deleteCardType(id)
DELETE /admin/card-types/:id → Backend soft-deletes (isActive=false)
Refetch list → Page updates
```
---
## 💾 Database Details
### CardType Model
- **Storage**: MySQL table `card_types`
- **Primary Key**: UUID
- **Important Field**: `isActive` (boolean, default: true)
- **Delete Strategy**: Soft delete (set isActive=false, not actually removed)
- **Relationships**:
- One-to-many with Membership
- One-to-many with Order
### Indexes
- `isActive` (for filtering active cards)
- `sortOrder` (for ordering)
---
## 🎨 UI/UX Details
### Page Layout
```
┌─ Toolbar ─────────────┐
│ Count + Add button │
├──────────────────────┤
│ Loading skeleton │ (while loading)
├──────────────────────┤
│ Card List │
├──────────────────────┤
│ Modal (Add/Edit) │ (if showModal=true)
└──────────────────────┘
```
### Card Display
- **Header**: Colored band (type-specific gradient)
- **Status tag**: "销售中" or "已下架"
- **Content**: Name, price, description, meta info
- **Actions**: 3 buttons (编辑, 上架/下架, 删除)
- **Inactive styling**: opacity: 0.6 when isActive=false
### Modal Form
```
Title: 新增卡种 / 编辑卡种
Fields:
- 卡种名称 (text input)
- 类型 (picker)
- 现价 (digit)
- 原价 (digit, optional)
- 次数 (number, optional)
- 有效天数 (number, default: 90)
- 排序值 (number, default: 0)
- 描述 (textarea, optional)
Buttons: [取消] [确认/保存中...]
```
---
## 🔐 Security & Auth
### Authentication
- All admin endpoints require JWT Bearer token
- Token stored in localStorage and included in all requests
### Authorization
- Admin endpoints require `UserRole.ADMIN`
- Enforced via RolesGuard on backend
### Public Endpoints
- GET /membership/card-types (no auth needed)
- Returns only `isActive=true` cards
---
## 📊 Validation Rules
### On Create
| Field | Required | Validation |
|-------|----------|-----------|
| name | ✓ | Non-empty string |
| type | ✓ | One of: TIMES, DURATION, TRIAL |
| durationDays | ✓ | Int, Min: 1 |
| price | ✓ | Number, Min: 0 |
| totalTimes | - | Int, Min: 1 (optional) |
| originalPrice | - | Number, Min: 0 (optional) |
| description | - | String, Max: 200 (optional) |
| sortOrder | - | Int, Min: 0 (optional, default: 0) |
### On Update
- All fields optional (partial update)
- Can include `isActive` for toggling status
---
## 💡 Price Handling
**Critical**: Prices are stored as **integers (cents)**, not floats
- In DB: `98000` (cents)
- In API: `{ price: 98000 }`
- Display: `¥980.00` (using formatPrice utility)
**Conversion**:
```typescript
// Display
formatPrice(cents: number): string {
return (cents / 100).toFixed(2)
}
// Store (frontend → backend)
// User inputs: "980"
// Send as: 98000 (no need to convert, prices are already in cents in the UI)
```
---
## 🧪 Testing Recommendations
### Unit Tests Needed
- [ ] CardType service methods (create, update, delete)
- [ ] Card type validation (DTO validation)
- [ ] Price formatting utilities
### Integration Tests Needed
- [ ] Admin endpoints require ADMIN role
- [ ] Public endpoint returns only active cards
- [ ] Soft delete sets isActive=false
### E2E Tests Needed (Frontend)
- [ ] Create card flow
- [ ] Edit card flow (including bug fix)
- [ ] Toggle status flow
- [ ] Delete card flow
- [ ] Modal closes properly on submit
- [ ] Modal closes on outside tap
- [ ] Modal closes on cancel button
---
## 🚀 Next Steps (If Implementing Bug Fix)
1. **Locate file**: `packages/app/src/pages/admin/card-types.vue`
2. **Find**: Line 67 with `<view class="ct-action-btn edit-btn" @tap="openEdit(ct)">`
3. **Change**: `@tap="openEdit(ct)"``@tap.stop="openEdit(ct)"`
4. **Also check**: Lines 6 and 77 (other buttons that might have same issue)
5. **Test**: Try editing a card - modal should stay open
---
## 📖 How to Use This Documentation
1. **Quick lookup**: Start with `CARD_TYPES_QUICK_REFERENCE.md`
2. **Understanding architecture**: Read `CARD_TYPES_FLOW_DIAGRAM.txt`
3. **Deep dive**: Consult `CARD_TYPES_ANALYSIS.md` for detailed information
4. **Bug fix**: Find solution in Quick Reference "THE BUG" section
---
## 📝 Summary Statistics
| Metric | Value |
|--------|-------|
| Files Analyzed | 13 |
| Total Lines of Code | ~1,800 |
| Endpoints | 5 |
| Card Type Categories | 3 |
| Core Operations | 4 (CRUD) |
| Bugs Identified | 1 |
| Bug Severity | High (UX-breaking) |
| Documentation Pages | 3 |
| Recommended Solution | @tap.stop modifier |
---
## ✅ Exploration Complete
All files related to the card types management feature have been thoroughly reviewed, analyzed, and documented.
**Key Achievement**: Identified and documented the root cause of the edit popup bug, along with three solution approaches.
**Ready to**:
- Implement bug fix
- Build additional features
- Optimize performance
- Add tests
- Deploy updates
---
**Generated**: 2026-04-05
**Location**: `/Users/richard/Documents/code/pilates/mp-pilates/`

View File

@@ -1,167 +0,0 @@
# Modal Event Handling Audit
## Overview
This document provides a security and event-handling audit of all modals in the application to identify and prevent event propagation issues similar to the card-types bug.
## Audit Results
### ✅ FIXED: packages/app/src/pages/admin/card-types.vue
**Status**: FIXED in commit a85270e
**Issue**: Action buttons inside a list card were closing the modal immediately when clicked due to event propagation to parent modal-mask.
**Solution**: Added `.stop` modifier to all three action button tap handlers:
- Edit button: `@tap.stop="openEdit(ct)"`
- Toggle button: `@tap.stop="toggleActive(ct)"`
- Delete button: `@tap.stop="confirmDelete(ct)"`
**Root Cause Pattern**:
- List items contain action buttons
- Action buttons are inside list cards
- Modal-mask has `@tap.self="closeModal"`
- Event from action button bubbles up through list card to modal-mask
---
### ✅ SAFE: packages/app/src/pages/admin/week-template.vue
**Status**: NO ACTION NEEDED
**Structure**:
- Template list (lines 30-56) - separate from modal
- Modal (lines 65+) - below the list
- Event handlers on template action buttons cannot reach modal-mask
**Reasoning**: The action buttons for edit/delete/toggle are on items in the template list, which is spatially separated from the modal-mask. The events cannot propagate upward to reach the modal-mask since the modal is rendered separately below the list.
---
### ✅ SAFE: packages/app/src/pages/admin/members.vue
**Status**: NO ACTION NEEDED
**Structure**:
- Members list uses `@tap="openDetail(m)"` on entire row element
- Modal is triggered with delay to handle event properly
- List items are separate from modal-mask
**Reasoning**: The entire member row has a single tap handler. The modal is opened as a detail view, not as an overlay that interferes with list item events. The architecture prevents event propagation issues.
---
### ✅ SAFE: components/BookingConfirmPopup.vue
**Status**: NO ACTION NEEDED (Special-case popup component)
**Structure**: Dedicated popup component with internal button handlers
---
## Event Propagation Risk Pattern
🚨 **RISK PATTERN** - High risk of event propagation issues:
```vue
<!-- List of items with action buttons -->
<view class="item-list">
<view v-for="item in items" :key="item.id" class="item-card">
<view class="item-actions">
<view @tap="handleAction1(item)">Action 1</view>
<view @tap="handleAction2(item)">Action 2</view>
</view>
</view>
</view>
<!-- Modal that appears on top -->
<view v-if="showModal" class="modal-mask" @tap.self="closeModal">
<view class="modal">...</view>
</view>
```
When an action button is tapped, the event bubbles: action button → item card → item-list → modal-mask
**SOLUTION**: Add `.stop` modifier to prevent bubbling:
```vue
<view @tap.stop="handleAction1(item)">Action 1</view>
```
---
## Preventive Measures
### 1. Code Review Checklist
When implementing modals with action buttons in lists:
- [ ] List items contain action buttons/clickable elements
- [ ] Modal-mask has `@tap.self="closeModal"` handler
- [ ] Check if tap events can bubble from buttons → modal-mask
- [ ] Add `.stop` modifier if event propagation risk exists
### 2. Testing Strategy
For any modal with nearby action buttons:
```
Test Scenario:
1. Click/tap action button that opens modal
2. Verify modal opens and stays open
3. Verify you can interact with modal content
4. Verify clicking outside modal (on mask) closes it
5. Verify multiple rapid clicks on action buttons don't cause flicker
```
### 3. Best Practices
```vue
<!-- SAFE: Action button prevents event propagation -->
<view @tap.stop="openModal(item)">Edit</view>
<!-- RISKY: Event can bubble to modal-mask -->
<view @tap="openModal(item)">Edit</view>
<!-- ALTERNATIVE: Use .prevent for links/special handlers -->
<view @tap.prevent="handleSpecial">Special</view>
<!-- ALTERNATIVE: Defer modal opening to next tick -->
<script>
async function openModal(item) {
editTarget.value = item
await nextTick()
showModal.value = true
}
</script>
```
---
## Summary
| File | Issue | Status | Solution |
|------|-------|--------|----------|
| card-types.vue | Event propagation | ✅ FIXED | Added `.stop` to 3 buttons |
| week-template.vue | N/A - Separate structure | ✅ SAFE | No action needed |
| members.vue | N/A - Single tap handler | ✅ SAFE | No action needed |
**Total Affected**: 1 file
**Total Fixed**: 1 file
**Total Safe**: 2 files
---
## Future Enhancements
1. **Automated Testing**: Add E2E tests for modal interactions
2. **ESLint Rule**: Consider adding custom rule to warn about `@tap` handlers on buttons inside modals
3. **Documentation**: Add event handling guidelines to project style guide
4. **Component Library**: Create a reusable `<Modal>` component with proper event handling built-in
---
## References
- Vue Event Handling: https://vuejs.org/guide/essentials/event-handling.html
- Event Modifiers: https://vuejs.org/guide/essentials/event-handling.html#event-modifiers
- Bug Fix Commit: a85270e - fix(admin): prevent edit modal from closing immediately on tap

View File

@@ -1,592 +0,0 @@
# Booking Page - Quick Reference & Code Snippets
## 🚀 Quick Start: Understanding the Flow
### Where Slots Come From
```typescript
// 1. Store calls API
packages/app/src/stores/booking.ts:17-27
async function fetchSlots(date: string) {
loadingSlots.value = true
try {
// GET /time-slot/available?date=2026-04-05
slots.value = await get<TimeSlotWithBookingStatus[]>(
'/time-slot/available',
{ date }
)
} catch (err) {
console.error('Fetch slots failed:', err)
slots.value = [] // ⚠️ Clears on error!
} finally {
loadingSlots.value = false
}
}
```
### Where Time Periods Are Defined
```typescript
// packages/shared/src/constants.ts:11-15
export const TIME_PERIODS = {
MORNING: { label: '上午', start: '06:00', end: '12:00' },
AFTERNOON: { label: '下午', start: '12:00', end: '18:00' },
EVENING: { label: '晚上', start: '18:00', end: '22:00' },
} as const
```
### Where Filtering Happens
```typescript
// pages/booking/index.vue:94-103
const filteredSlots = computed<TimeSlotWithBookingStatus[]>(() => {
const slots = bookingStore.slots as TimeSlotWithBookingStatus[]
if (!selectedPeriod.value) return [...slots]
const period = TIME_PERIODS[selectedPeriod.value]
return slots.filter((slot) => {
const t = slot.startTime // "09:00", "10:00", etc
return t >= period.start && t < period.end
})
})
```
### Slot Rendering
```vue
<!-- pages/booking/index.vue:34-42 -->
<view v-else class="slot-list">
<SlotCard
v-for="slot in filteredSlots"
:key="slot.id"
:slot="slot"
@book="onBookTap"
@cancel="onCancelTap"
/>
</view>
```
---
## 🔍 Finding Specific Things
### Q: Where do the time slot types come from?
**A:** `packages/shared/src/types/time-slot.ts`
```typescript
interface TimeSlotWithBookingStatus extends TimeSlot {
readonly isBookedByMe: boolean // true if user booked it
readonly myBookingId: string | null // needed for cancellation
}
interface TimeSlot {
readonly id: string // UUID
readonly date: string // "2026-04-05"
readonly startTime: string // "09:00"
readonly endTime: string // "10:00"
readonly capacity: number // 1 (for private lessons)
readonly bookedCount: number // 0 or 1
readonly status: TimeSlotStatus // OPEN|FULL|CLOSED
readonly source: TimeSlotSource // TEMPLATE|MANUAL
readonly templateId: string | null
readonly createdAt: string
readonly updatedAt: string
}
```
### Q: Where is the membership selection happening?
**A:** `components/BookingConfirmPopup.vue:136-147`
```typescript
const selectedMembershipId = ref<string>('')
watch(
[() => props.visible, () => props.memberships],
([visible, memberships]) => {
if (visible && memberships.length > 0) {
selectedMembershipId.value = memberships[0].id // Auto-select first
}
},
{ immediate: true },
)
```
### Q: Where are the button states determined?
**A:** `components/SlotCard.vue:15-45`
```vue
<!-- OPEN + not booked by me -->
<template v-if="slot.status === TimeSlotStatus.OPEN && !slot.isBookedByMe">
<view class="btn btn-book" @tap.stop="emit('book', slot)">
<text class="btn-text">可预约</text>
</view>
</template>
<!-- OPEN + booked by me -->
<template v-else-if="slot.status === TimeSlotStatus.OPEN && slot.isBookedByMe">
<view class="booked-row">
<view class="badge-booked">
<text class="badge-text">已预约</text>
</view>
<view class="btn-cancel" @tap.stop="emit('cancel', slot)">
<text class="btn-cancel-text">取消</text>
</view>
</view>
</template>
<!-- FULL or CLOSED -->
<template v-else>
<view class="btn btn-disabled">
<text class="btn-text">
{{ slot.status === TimeSlotStatus.FULL ? '已约满' : '已关闭' }}
</text>
</view>
</template>
```
### Q: Where is the API request actually made?
**A:** `utils/request.ts:22-59`
```typescript
export function request<T>(options: RequestOptions): Promise<T> {
return new Promise((resolve, reject) => {
const token = uni.getStorageSync('token') as string
uni.request({
url: `${BASE_URL}${options.url}`, // BASE_URL = http://localhost:3000/api
method: options.method || 'GET',
data: options.data,
header: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.header,
},
success: (res) => {
if (res.statusCode === 401) {
uni.removeStorageSync('token')
uni.showToast({ title: '请重新登录', icon: 'none' })
reject(new Error('Unauthorized'))
return
}
if (res.statusCode >= 400) {
const body = res.data as ApiResponse<unknown>
reject(new Error(body?.message || `请求失败 (${res.statusCode})`))
return
}
const body = res.data as ApiResponse<T>
if (body.success) {
resolve(body.data as T) // ← Extract data from ApiResponse
} else {
reject(new Error(body.message || '请求失败'))
}
},
fail: (err) => {
reject(new Error(err.errMsg || '网络请求失败'))
},
})
})
}
```
---
## 🐛 Debugging Tips
### Tip 1: Check what's in the store
```typescript
// In browser console while in booking page:
console.log('Slots:', JSON.stringify(uni.$u.pinia.state.value.booking.slots, null, 2))
console.log('Selected period:', uni.$u.pinia.state.value.booking.selectedPeriod)
```
### Tip 2: Log slot filtering
```typescript
// Add to pages/booking/index.vue filteredSlots computed:
const filteredSlots = computed<TimeSlotWithBookingStatus[]>(() => {
const slots = bookingStore.slots as TimeSlotWithBookingStatus[]
if (!selectedPeriod.value) {
console.log('No period filter, showing all slots:', slots.length)
return [...slots]
}
const period = TIME_PERIODS[selectedPeriod.value]
console.log(`Filtering by ${selectedPeriod.value}:`, period)
console.log('All slot times:', slots.map(s => s.startTime))
const filtered = slots.filter((slot) => {
const t = slot.startTime
const matches = t >= period.start && t < period.end
if (!matches) console.log(`${t} not in [${period.start}, ${period.end})`)
return matches
})
console.log('Filtered result:', filtered.length)
return filtered
})
```
### Tip 3: Verify API response
```typescript
// In stores/booking.ts fetchSlots():
async function fetchSlots(date: string) {
loadingSlots.value = true
try {
console.log('Fetching slots for date:', date)
slots.value = await get<TimeSlotWithBookingStatus[]>(
'/time-slot/available',
{ date }
)
console.log('Received slots:', slots.value)
console.log('Slot count:', slots.value.length)
if (slots.value.length > 0) {
console.log('First slot:', JSON.stringify(slots.value[0], null, 2))
}
} catch (err) {
console.error('Fetch slots failed:', err)
slots.value = []
} finally {
loadingSlots.value = false
}
}
```
### Tip 4: Check network requests
```typescript
// Open WeChat DevTools → Network tab
// Look for GET request to /time-slot/available
// Check:
// ✓ URL has ?date=YYYY-MM-DD
// ✓ Authorization header exists
// ✓ Response status 200
// ✓ Response body has "success": true
```
---
## ❌ Common Issues & Solutions
### Issue 1: Slots not loading
**Symptoms:**
- Page shows "当日暂无可约时段" (no slots)
- No error message
**Check list:**
```typescript
// 1. Is API endpoint correct?
// Check: /time-slot/available?date=2026-04-05
// Should return TimeSlotWithBookingStatus[]
// 2. Is date format correct?
// Page sends: formatDate(new Date()) → "2026-04-05"
// API expects: "YYYY-MM-DD"
console.log(formatDate(new Date())) // Should output: "2026-04-05"
// 3. Is authentication working?
console.log('Token:', uni.getStorageSync('token'))
// 4. Check for errors in console
// If fetchSlots fails, slots.value becomes []
```
**Solution:**
```typescript
// In bookingStore.fetchSlots(), add error state:
const error = ref<string | null>(null)
async function fetchSlots(date: string) {
loadingSlots.value = true
error.value = null // Clear previous error
try {
slots.value = await get<TimeSlotWithBookingStatus[]>(
'/time-slot/available',
{ date }
)
} catch (err) {
console.error('Fetch slots failed:', err)
error.value = err instanceof Error ? err.message : '加载失败'
slots.value = []
} finally {
loadingSlots.value = false
}
}
// Then in page template:
<view v-if="error" class="error-wrap">
<text>{{ error }}</text>
<view @tap="loadSlots(selectedDate)"></view>
</view>
```
### Issue 2: Time period filtering not working
**Symptoms:**
- Select "上午" (morning) but all slots still show
- Or vice versa
**Check:**
```typescript
// 1. Verify TIME_PERIODS constant
console.log('TIME_PERIODS:', TIME_PERIODS)
// 2. Check selectedPeriod value
console.log('Selected period:', selectedPeriod.value)
// 3. Verify slot.startTime format
// Should be "HH:MM" like "09:00", not "09:00:00"
bookingStore.slots.forEach(slot => {
console.log('Slot time:', slot.startTime, 'format ok?', /^\d{2}:\d{2}$/.test(slot.startTime))
})
// 4. Test filtering manually
const slot = bookingStore.slots[0]
const period = TIME_PERIODS.MORNING
console.log(`${slot.startTime} >= ${period.start}?`, slot.startTime >= period.start)
console.log(`${slot.startTime} < ${period.end}?`, slot.startTime < period.end)
```
**Solution:**
```typescript
// If time format is "09:00:00", slice it:
const filteredSlots = computed<TimeSlotWithBookingStatus[]>(() => {
const slots = bookingStore.slots as TimeSlotWithBookingStatus[]
if (!selectedPeriod.value) return [...slots]
const period = TIME_PERIODS[selectedPeriod.value]
return slots.filter((slot) => {
// Ensure HH:MM format
const t = slot.startTime.slice(0, 5) // "09:00:00" → "09:00"
return t >= period.start && t < period.end
})
})
```
### Issue 3: Booking button not responding
**Symptoms:**
- Click "可预约" but nothing happens
- No modal appears
**Check:**
```typescript
// 1. Is slot.status correct?
console.log('Slot status:', slot.status)
// Should be "OPEN" to show book button
// 2. Is isBookedByMe false?
console.log('Is booked by me?', slot.isBookedByMe)
// Should be false to show book button
// 3. Is onBookTap being called?
// Add to pages/booking/index.vue:
async function onBookTap(slot: TimeSlotWithBookingStatus) {
console.log('Book tapped for slot:', slot) // ← Should log
// Rest of code...
}
// 4. Is userStore.loggedIn true?
console.log('Logged in?', userStore.loggedIn)
```
### Issue 4: Membership not showing in popup
**Symptoms:**
- Booking popup appears but no membership card shown
- "暂无可用会员卡" displayed
**Check:**
```typescript
// 1. Are memberships loaded?
console.log('Memberships:', userStore.memberships)
// 2. Are any memberships ACTIVE?
console.log('Active memberships:', userStore.activeMemberships)
console.log('Has valid membership?', userStore.hasValidMembership)
// 3. Are memberships passed to popup?
// In pages/booking/index.vue:
<BookingConfirmPopup
:memberships="userStore.activeMemberships as MembershipWithCardType[]"
...
/>
console.log('Popup passed memberships:', userStore.activeMemberships)
```
**Solution:**
```typescript
// In onMounted:
onMounted(async () => {
if (userStore.loggedIn && userStore.activeMemberships.length === 0) {
console.log('Fetching memberships...')
try {
await userStore.fetchMemberships()
console.log('Memberships loaded:', userStore.activeMemberships)
} catch (err) {
console.error('Failed to fetch memberships:', err)
uni.showToast({ title: '加载会员卡失败', icon: 'none' })
}
}
await loadSlots(selectedDate.value)
})
```
---
## 📊 Capacity Display Logic
### How Capacity Color is Determined
```typescript
// components/SlotCard.vue:69-81
const capacityLabel = computed(() => {
const { bookedCount, capacity, status } = props.slot
if (status === TimeSlotStatus.CLOSED) return '已关闭'
return `${bookedCount}/${capacity}`
})
const capacityClass = computed(() => {
const { bookedCount, capacity, status } = props.slot
if (status === TimeSlotStatus.CLOSED) return 'cap-closed'
if (status === TimeSlotStatus.FULL) return 'cap-full'
if (bookedCount >= capacity * 0.8) return 'cap-almost'
return 'cap-open'
})
// Color mapping in styles:
// cap-open: #f0faf3 bg, #4caf50 text (green) - <80% booked
// cap-almost: #fff8ed bg, #f59e0b text (orange) - ≥80% booked
// cap-full: #fef0f0 bg, #ef4444 text (red) - status: FULL
// cap-closed: #f5f5f5 bg, #999 text (gray) - status: CLOSED
```
### Example Calculations
```typescript
// Slot 1: capacity=1, bookedCount=0, status=OPEN
// 0/1 人 in green badge (0% booked)
// Slot 2: capacity=1, bookedCount=1, status=OPEN
// 1/1 人 in red badge (100% booked ≥ 80%)
// Slot 3: capacity=5, bookedCount=4, status=OPEN
// 4/5 人 in orange badge (80% booked ≥ 80%)
// Slot 4: capacity=5, bookedCount=3, status=OPEN
// 3/5 人 in green badge (60% booked < 80%)
```
---
## 🔗 API Contract Summary
### GET /time-slot/available
**Request:**
```
GET /api/time-slot/available?date=2026-04-05
Authorization: Bearer <token>
```
**Response (200 OK):**
```json
{
"success": true,
"data": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"date": "2026-04-05",
"startTime": "09:00",
"endTime": "10:00",
"capacity": 1,
"bookedCount": 0,
"status": "OPEN",
"source": "MANUAL",
"templateId": null,
"createdAt": "2026-04-01T10:00:00Z",
"updatedAt": "2026-04-05T09:00:00Z",
"isBookedByMe": false,
"myBookingId": null
}
],
"message": null
}
```
**Error (400):**
```json
{
"success": false,
"data": null,
"message": "Invalid date format"
}
```
### POST /booking
**Request:**
```json
POST /api/booking
{
"timeSlotId": "550e8400-e29b-41d4-a716-446655440000",
"membershipId": "220e8400-e29b-41d4-a716-446655440111"
}
```
**Response (201):**
```json
{
"success": true,
"data": {
"id": "550e8400-e29b-41d4-a716-446655440222",
"userId": "user-123",
"timeSlotId": "550e8400-e29b-41d4-a716-446655440000",
"membershipId": "220e8400-e29b-41d4-a716-446655440111",
"status": "CONFIRMED",
"bookedAt": "2026-04-05T10:30:00Z",
"courseDate": "2026-04-05",
"courseTime": "09:00",
"instructorName": "instructor name",
"isCompleted": false
},
"message": null
}
```
### PUT /booking/:id/cancel
**Request:**
```
PUT /api/booking/550e8400-e29b-41d4-a716-446655440222/cancel
```
**Response (200):**
```json
{
"success": true,
"data": {
"id": "550e8400-e29b-41d4-a716-446655440222",
"status": "CANCELLED",
"cancelledAt": "2026-04-05T10:35:00Z"
},
"message": null
}
```
---
## 🎯 Next Steps for Debugging
1. **Verify API Endpoint**
- Open DevTools → Network
- Check `/time-slot/available?date=...` request
- Confirm response has `"success": true`
- Confirm data array is not empty
2. **Check Store State**
- Add console.logs to bookingStore.fetchSlots()
- Verify slots are set correctly
- Check loadingSlots toggle
3. **Verify Computed Properties**
- Log filteredSlots in component
- Check if filtering logic works
- Verify slot.startTime format
4. **Test User Interaction**
- Click date item → verify onDateSelect fires
- Click period tab → verify onPeriodChange fires
- Click book button → verify onBookTap fires
- Check modals appear
5. **Check Mobile-Specific Issues**
- Test in WeChat DevTools
- Check rpx calculations
- Verify touch events work

View File

@@ -1,244 +0,0 @@
# WeChat Mini-Program Admin Scheduling - Documentation Index
**Created**: 2026-04-05
**Project**: mp-pilates (Pilates Studio Booking System)
---
## 📚 Documentation Files
This exploration contains **3 comprehensive documents** about the admin scheduling/排课设置 system:
### 1. **ADMIN_SCHEDULING_EXPLORATION.md** (24 KB, 803 lines)
**Purpose**: Complete deep-dive into the scheduling system
**Sections**:
- Executive Summary
- File Structure (frontend, backend, shared)
- 4 Key Components (Admin Dashboard, Week Templates, Slot Adjustment, Admin Store)
- Backend Architecture (Controllers, Services, Slot Generator)
- Data Flow & User Journey
- Constants & Utilities
- Permission Model
- Implementation Status
- Edge Cases
- UI Design Patterns
- Deployment & Configuration
**Best for**: Understanding the complete architecture and how everything connects
---
### 2. **SCHEDULING_FLOW_DIAGRAM.md** (13 KB, 271 lines)
**Purpose**: Visual flowcharts and architecture diagrams
**Sections**:
- Component Architecture (visual tree)
- Data Flow: Template → Slots (visual flowchart)
- State Management breakdown
- API Endpoints Summary
- Entity Relationships (ER diagram)
- Weekday Mapping (ISO vs JS conversion)
- Timeline Example (realistic scenario)
**Best for**: Quick visual understanding of the flow and architecture
---
### 3. **SCHEDULING_QUICK_REFERENCE.md** (7.9 KB, 296 lines)
**Purpose**: Quick lookup guide for developers
**Sections**:
- Quick Links to Key Files (with line numbers)
- The Flow in 30 Seconds
- Core Entities (WeekTemplate, TimeSlot)
- API Endpoints (with JSON examples)
- UI State Management
- Permissions & Auth
- Important Constants
- Common Gotchas (5 key points)
- Usage Example (step-by-step)
- Related Components
- Scalability Notes
**Best for**: Developers jumping into the code for the first time
---
## 🎯 Choose Your Path
### If you want to...
**Understand the big picture**
→ Read: `SCHEDULING_FLOW_DIAGRAM.md`
→ Then: `ADMIN_SCHEDULING_EXPLORATION.md` (section 2)
**Start coding immediately**
→ Read: `SCHEDULING_QUICK_REFERENCE.md`
→ Then: Jump to specific file links
**Debug a specific issue**
→ Read: `SCHEDULING_QUICK_REFERENCE.md` (Common Gotchas)
→ Then: Search in `ADMIN_SCHEDULING_EXPLORATION.md`
**Understand data flow**
→ Read: `SCHEDULING_FLOW_DIAGRAM.md` (Data Flow section)
→ Then: `ADMIN_SCHEDULING_EXPLORATION.md` (section 7: Data Flow)
---
## 🔑 Key Files by Role
### Frontend Developer
**Must Read**:
- `SCHEDULING_QUICK_REFERENCE.md` → UI State Management
- `packages/app/src/pages/admin/week-template.vue` (500 lines)
- `packages/app/src/pages/admin/slot-adjust.vue` (428 lines)
- `packages/app/src/stores/admin.ts` (171 lines)
### Backend Developer
**Must Read**:
- `SCHEDULING_QUICK_REFERENCE.md` → API Endpoints
- `packages/server/src/time-slot/time-slot.controller.ts`
- `packages/server/src/time-slot/slot-generator.service.ts`
- `packages/server/src/time-slot/time-slot.service.ts`
### Full-Stack Developer
**Must Read**: All documentation files in order:
1. `SCHEDULING_QUICK_REFERENCE.md` (5 min)
2. `SCHEDULING_FLOW_DIAGRAM.md` (10 min)
3. `ADMIN_SCHEDULING_EXPLORATION.md` (20 min)
---
## 🎓 Learning Timeline
### Day 1: Orientation (30 minutes)
- Read: `SCHEDULING_QUICK_REFERENCE.md` section "The Flow: In 30 Seconds"
- Skim: `SCHEDULING_FLOW_DIAGRAM.md`
### Day 2: Deep Dive (1-2 hours)
- Read: `SCHEDULING_FLOW_DIAGRAM.md` (entire)
- Read: `ADMIN_SCHEDULING_EXPLORATION.md` (sections 1-3)
### Day 3: Implementation (ongoing)
- Refer to: `SCHEDULING_QUICK_REFERENCE.md` as needed
- Cross-reference: `ADMIN_SCHEDULING_EXPLORATION.md` sections 4-8
- Check: Backend/Frontend specific sections
---
## 🔗 File Paths: Quick Lookup
| Component | Path | Lines |
|-----------|------|-------|
| Admin Dashboard | `packages/app/src/pages/admin/index.vue` | 177 |
| **Week Templates** | `packages/app/src/pages/admin/week-template.vue` | 500 ⭐ |
| Slot Adjustment | `packages/app/src/pages/admin/slot-adjust.vue` | 428 |
| Admin Store | `packages/app/src/stores/admin.ts` | 171 |
| API Controller | `packages/server/src/time-slot/time-slot.controller.ts` | 92 |
| API Service | `packages/server/src/time-slot/time-slot.service.ts` | 142 |
| Slot Generator | `packages/server/src/time-slot/slot-generator.service.ts` | 172 |
| Types: Templates | `packages/shared/src/types/week-template.ts` | 19 |
| Types: Slots | `packages/shared/src/types/time-slot.ts` | 30 |
| Constants | `packages/shared/src/constants.ts` | 22 |
| Utilities | `packages/app/src/utils/format.ts` | 47 |
⭐ = Main scheduling component (排课设置)
---
## 📊 System Overview
```
┌─────────────────────────────────────────────────────────┐
│ ADMIN SCHEDULING SYSTEM │
├─────────────────────────────────────────────────────────┤
│ │
│ Frontend (Vue 3 + TypeScript) │
│ ├─ week-template.vue (templates CRUD) │
│ ├─ slot-adjust.vue (manual operations) │
│ └─ admin.ts (Pinia store) │
│ │
│ Backend (NestJS + Prisma) │
│ ├─ time-slot.controller.ts (API routes) │
│ ├─ time-slot.service.ts (business logic) │
│ └─ slot-generator.service.ts (auto-generation) │
│ │
│ Database (PostgreSQL/MySQL) │
│ ├─ WeekTemplate (recurring schedule rules) │
│ ├─ TimeSlot (actual bookable slots) │
│ └─ Booking (user reservations) │
│ │
└─────────────────────────────────────────────────────────┘
```
---
## 🚀 Quick Start Checklist
- [ ] Read `SCHEDULING_QUICK_REFERENCE.md` (5 min)
- [ ] Skim `SCHEDULING_FLOW_DIAGRAM.md` (5 min)
- [ ] Open `packages/app/src/pages/admin/week-template.vue`
- [ ] Open `packages/server/src/time-slot/slot-generator.service.ts`
- [ ] Bookmark this index file for reference
- [ ] Ask questions about specific sections in the docs
---
## 📝 Terms & Definitions
| Term | Definition |
|------|-----------|
| **WeekTemplate** | Recurring schedule rule (e.g., "every Monday 9-10 AM") |
| **TimeSlot** | Actual bookable time (e.g., "Monday, April 6, 9-10 AM") |
| **排课设置** | Schedule setup (admin template management) |
| **临时调整** | Temporary adjustments (manual slot operations) |
| **isDirty** | Flag indicating unsaved changes |
| **Atomic** | All-or-nothing database transaction |
| **skipDuplicates** | Prisma option to ignore duplicate records on batch insert |
| **ISO Weekday** | 1=Monday, 2=Tuesday, ..., 7=Sunday |
---
## 🆘 Getting Help
### Question Type → Documentation
**"How does admin add a new class?"**
`SCHEDULING_QUICK_REFERENCE.md` → Usage Example
**"What API endpoints exist?"**
`SCHEDULING_QUICK_REFERENCE.md` → API Endpoints
→ OR `ADMIN_SCHEDULING_EXPLORATION.md` → Backend Architecture
**"How do templates become slots?"**
`SCHEDULING_FLOW_DIAGRAM.md` → Data Flow section
**"What database schema?"**
`SCHEDULING_QUICK_REFERENCE.md` → Core Entities
→ OR `SCHEDULING_FLOW_DIAGRAM.md` → Entity Relationships
**"Where does X file?"**
`SCHEDULING_QUICK_REFERENCE.md` → File Paths lookup table
---
## ✅ Verification Checklist
- [x] All 3 documentation files created
- [x] 803 + 271 + 296 = 1,370 lines of documentation
- [x] Complete file paths documented
- [x] API endpoints listed with examples
- [x] Data flow diagrams included
- [x] Common gotchas documented
- [x] Usage examples provided
- [x] Scalability notes included
- [x] Permission model explained
- [x] Timezone handling noted
---
**Last Updated**: 2026-04-05
**Status**: Complete and ready for reference

View File

@@ -1,271 +0,0 @@
# Admin Scheduling Flow Diagram
## Component Architecture
```
┌─────────────────────────────────────────────────────────┐
│ Admin Dashboard │
│ (pages/admin/index.vue) │
│ │
│ 📅 排课设置 🔧 临时调整 👥 会员 📋 订单 💳 卡 🏢 工作室
└─────────────────────────────────────────────────────────┘
└─► 📅 排课设置 (Week Template)
└─────────────────────────────────────────┐
│ pages/admin/week-template.vue │
│ ================================ │
│ │
│ 1. Fetch Templates (onMounted) │
│ └─ GET /admin/week-template │
│ │
│ 2. Display grouped by day (Mon-Sun) │
│ │
│ 3. Add/Edit/Delete/Toggle locally │
│ └─ isDirty flag = true │
│ │
│ 4. Save All Changes (bottom bar) │
│ └─ PUT /admin/week-template │
│ (Full template array) │
│ │
│ 5. Backend transaction: │
│ - DELETE all templates │
│ - CREATE new templates │
└────────────────────────────────────────┘
└─► 🔧 临时调整 (Slot Adjustment - 3 Tabs)
└─────────────────────────────────────────┐
│ pages/admin/slot-adjust.vue │
│ ================================ │
│ │
│ TAB 0: 新增时段 (Add Manual Slot) │
│ ├─ Date picker │
│ ├─ Time pickers │
│ ├─ Capacity input │
│ └─ POST /admin/time-slot/manual │
│ └─ Creates slot with source=MANUAL │
│ │
│ TAB 1: 关闭时段 (Close Slots) │
│ ├─ Date picker │
│ ├─ Fetch slots for date │
│ │ └─ GET /admin/time-slots?date=XXX │
│ ├─ Display with status badges │
│ │ (OPEN/FULL/CLOSED) │
│ └─ PUT /admin/time-slot/:id/close │
│ │
│ TAB 2: 批量生成 (Batch Generate) │
│ ├─ Start/end date pickers │
│ ├─ POST /admin/generate-slots │
│ └─ Backend: │
│ 1. Fetch active WeekTemplates │
│ 2. For each day in range: │
│ - Get ISO weekday (1-7) │
│ - Find matching templates │
│ - Create TimeSlot records │
│ 3. Returns { count: N } │
└────────────────────────────────────────┘
```
## Data Flow: Template → Slots
```
┌──────────────────────────────────────────────────────────────────┐
│ ADMIN TEMPLATE SETUP │
│ (weeks/admin/week-template.vue) │
└──────────────────────────────────────────────────────────────────┘
┌───────────────────────────────────┐
│ Admin configures templates: │
│ │
│ 周一: 09:00-10:00 (10 ppl) │
│ 周一: 18:00-19:00 (8 ppl) │
│ 周三: 10:00-11:00 (12 ppl) │
│ 周五: 18:00-20:00 (15 ppl) │
└───────────────────────────────────┘
┌───────────────────────────────────┐
│ PUT /admin/week-template │
│ (All templates replaced) │
└───────────────────────────────────┘
┌────────────────────────────────────────────┐
│ Backend: Delete all, Create new (atomic) │
└────────────────────────────────────────────┘
┌────────────────────────────────────────────┐
│ Scheduler (nightly cron or manual trigger)│
│ POST /admin/generate-slots (14 days) │
└────────────────────────────────────────────┘
┌────────────────────────────────────────────┐
│ SlotGeneratorService.generateSlots() │
│ │
│ For each active template: │
│ For date in next 14 days: │
│ If template.dayOfWeek == date.dayOfWeek:
│ CREATE TimeSlot { │
│ date, startTime, endTime, │
│ capacity, source=TEMPLATE, │
│ templateId │
│ } │
└────────────────────────────────────────────┘
┌────────────────────────────────────────────┐
│ GENERATED TIME SLOTS │
│ │
│ 2026-04-06 (Mon): │
│ 09:00-10:00 (10 ppl, OPEN) │
│ 18:00-19:00 (8 ppl, OPEN) │
│ │
│ 2026-04-08 (Wed): │
│ 10:00-11:00 (12 ppl, OPEN) │
│ │
│ 2026-04-11 (Fri): │
│ 18:00-20:00 (15 ppl, OPEN) │
│ ... (more dates) │
└────────────────────────────────────────────┘
┌────────────────────────────────────────────┐
│ Members can book available slots │
│ GET /time-slot/available?date=YYYY-MM-DD
└────────────────────────────────────────────┘
```
## State Management
### Component State (week-template.vue)
```typescript
templates: LocalTemplate[] Main data array
loading: boolean Fetch state
saving: boolean Save state
isDirty: boolean "Save bar" trigger
showModal: boolean Modal visibility
editTarget: LocalTemplate | null Which template is being edited
form: { Modal form data
dayIdx: number
startTime: string
endTime: string
capacityStr: string
}
grouped: Computed<Record<number, LocalTemplate[]>> Grouped by dayOfWeek
```
### Store State (stores/admin.ts)
```typescript
weekTemplates: WeekTemplate[] Cached from server
cardTypes: CardType[]
studioConfig: StudioConfig | null
// ...other admin state
```
## API Endpoints Summary
### Week Templates
```
GET /admin/week-template Fetch all templates
PUT /admin/week-template Replace all templates
```
### Time Slots
```
GET /admin/time-slots?date=YYYY-MM-DD Fetch slots for date
POST /admin/time-slot/manual Create manual slot
PUT /admin/time-slot/:id/close Close a slot
POST /admin/generate-slots Generate slots from templates
```
### Public Endpoints
```
GET /time-slot/available?date=YYYY-MM-DD For members
GET /time-slot/:id For members
```
## Entity Relationships
```
┌─────────────────────┐
│ WeekTemplate │
├─────────────────────┤
│ id │
│ dayOfWeek (1-7) │
│ startTime │
│ endTime │
│ capacity │
│ isActive │
└─────────────────────┘
│ (1:N)
┌─────────────────────┐ ┌──────────────────┐
│ TimeSlot │ │ Booking (M:1) │
├─────────────────────┤ ├──────────────────┤
│ id │◄─────│ timeSlotId │
│ date │ │ userId │
│ startTime │ │ status │
│ endTime │ └──────────────────┘
│ capacity │
│ bookedCount │
│ status │
│ source (TEMPLATE/ │
│ MANUAL) │
│ templateId (FK) │
└─────────────────────┘
```
## Weekday Mapping
### Frontend Picker (dayOptions)
```
Index 0: 周一 (Monday) ──► dayOfWeek = 1
Index 1: 周二 (Tuesday) ──► dayOfWeek = 2
Index 2: 周三 (Wednesday) ──► dayOfWeek = 3
Index 3: 周四 (Thursday) ──► dayOfWeek = 4
Index 4: 周五 (Friday) ──► dayOfWeek = 5
Index 5: 周六 (Saturday) ──► dayOfWeek = 6
Index 6: 周日 (Sunday) ──► dayOfWeek = 7
```
### Backend Conversion (slot-generator.service.ts)
```typescript
JS getDay(): 0=Sun, 1=Mon, 2=Tue, ..., 6=Sat
toIsoWeekday()
ISO weekday: 1=Mon, 2=Tue, ..., 7=Sun
```
## Timeline Example
```
TODAY: 2026-04-05 (Sunday)
Admin actions:
1. Sets up weekly templates for Mon-Fri
2. Taps "保存全部更改"
3. PUT /admin/week-template sent
Backend scheduler (daily at midnight):
4. Runs generateSlots(14)
5. Tomorrow is 2026-04-06 (Monday)
6. Generates slots for Apr 6-19 (next 14 days)
7. Creates TimeSlots based on active templates:
Generated slots:
2026-04-06 (Mon): 09:00-10:00, 18:00-19:00
2026-04-07 (Tue): (none if no templates)
2026-04-08 (Wed): 10:00-11:00
2026-04-09 (Thu): (none if no templates)
2026-04-10 (Fri): 18:00-20:00
2026-04-11 (Sat): (none - weekend)
2026-04-12 (Sun): (none - weekend)
...repeats until 2026-04-19
Members can book from Apr 6 onwards
```

View File

@@ -1,296 +0,0 @@
# Admin Scheduling - Quick Reference Guide
## 🎯 Quick Links to Key Files
### Frontend Components
| File | Lines | Purpose |
|------|-------|---------|
| `packages/app/src/pages/admin/index.vue` | 1-177 | Admin dashboard, 6 nav items |
| `packages/app/src/pages/admin/week-template.vue` | 1-500 | **MAIN: Schedule template management** |
| `packages/app/src/pages/admin/slot-adjust.vue` | 1-428 | 3 tabs: add/close/generate slots |
| `packages/app/src/stores/admin.ts` | 1-171 | API calls (Pinia store) |
### Backend Services
| File | Purpose |
|------|---------|
| `packages/server/src/time-slot/time-slot.controller.ts` | API endpoints (/admin/*) |
| `packages/server/src/time-slot/time-slot.service.ts` | Template & slot logic |
| `packages/server/src/time-slot/slot-generator.service.ts` | Auto-generate slots from templates |
| `packages/server/src/time-slot/dto/week-template.dto.ts` | Input validation |
### Shared Types & Constants
| File | Exports |
|------|---------|
| `packages/shared/src/types/week-template.ts` | `WeekTemplate`, `WeekTemplateInput` |
| `packages/shared/src/types/time-slot.ts` | `TimeSlot`, `CreateManualSlotDto` |
| `packages/shared/src/constants.ts` | `WEEKDAY_LABELS`, `SLOT_GENERATION_DAYS`, etc. |
---
## 🔄 The Flow: In 30 Seconds
```
Admin edits templates
isDirty = true → Save bar appears
Admin taps "保存全部更改"
PUT /admin/week-template (full array)
Backend: DELETE all, CREATE new (atomic)
Scheduler triggers (nightly or manual)
POST /admin/generate-slots
SlotGeneratorService fetches active templates
For each day (next 14 days):
Match templates by ISO weekday
Create TimeSlot records (source=TEMPLATE)
Members see slots and can book
```
---
## 📊 Core Entities
### WeekTemplate (Database)
```typescript
id: string // UUID
dayOfWeek: number // 1=Mon, 2=Tue, ..., 7=Sun
startTime: string // "09:00"
endTime: string // "10:00"
capacity: number // Max bookings
isActive: boolean // Enabled/disabled
createdAt: string
updatedAt: string
```
### TimeSlot (Database)
```typescript
id: string
date: string // YYYY-MM-DD
startTime: string
endTime: string
capacity: number
bookedCount: number // How many booked
status: "OPEN" | "FULL" | "CLOSED"
source: "TEMPLATE" | "MANUAL"
templateId: string | null // Links to WeekTemplate
createdAt: string
updatedAt: string
```
---
## 🌐 API Endpoints
### GET /admin/week-template
Returns all templates (ordered by dayOfWeek ASC, startTime ASC)
```json
[
{
"id": "uuid1",
"dayOfWeek": 1,
"startTime": "09:00",
"endTime": "10:00",
"capacity": 10,
"isActive": true,
"createdAt": "2026-04-05T00:00:00Z",
"updatedAt": "2026-04-05T00:00:00Z"
}
]
```
### PUT /admin/week-template
Replace all templates (atomic transaction)
```json
{
"templates": [
{ "dayOfWeek": 1, "startTime": "09:00", "endTime": "10:00", "capacity": 10, "isActive": true },
{ "dayOfWeek": 1, "startTime": "18:00", "endTime": "19:00", "capacity": 8, "isActive": true },
{ "dayOfWeek": 3, "startTime": "10:00", "endTime": "11:00", "capacity": 12, "isActive": false }
]
}
```
### POST /admin/time-slot/manual
Create a one-off slot
```json
{
"date": "2026-04-15",
"startTime": "14:00",
"endTime": "15:00",
"capacity": 10
}
```
### PUT /admin/time-slot/:id/close
Close a slot (changes status to CLOSED)
### POST /admin/generate-slots
Generate slots for next 14 days from active templates
Response: `{ "count": 28 }`
---
## 🎨 UI State Management
### week-template.vue Local State
```typescript
// Main data
templates: LocalTemplate[] // All templates
grouped: Computed<Record<number, LocalTemplate[]>> // By dayOfWeek
// UI states
loading: boolean // Initial fetch
saving: boolean // Save in progress
isDirty: boolean // Show save bar?
showModal: boolean // Show add/edit modal?
editTarget: LocalTemplate | null // Editing which template?
// Modal form
form: {
dayIdx: number // 0-6 (picker index)
startTime: string // "09:00"
endTime: string // "10:00"
capacityStr: string // User input as string
}
```
### Key Computed
```typescript
const grouped = computed(() => {
// Groups templates by dayOfWeek for rendering
// Sorts by day number ascending (1-7)
// Returns: { 1: [...], 3: [...], 5: [...], ... }
})
```
---
## 🔐 Permissions & Auth
All `/admin/*` endpoints require:
1. Valid JWT token in `Authorization: Bearer <token>` header
2. User role must be `UserRole.ADMIN`
3. Guards: `@UseGuards(JwtAuthGuard, RolesGuard)`
---
## 🧮 Important Constants
From `packages/shared/src/constants.ts`:
```typescript
SLOT_GENERATION_DAYS = 14 // Generate 14 days ahead
DEFAULT_SLOT_CAPACITY = 1 // Private lesson default
DEFAULT_CANCEL_HOURS_LIMIT = 2 // Cancel up to 2 hours before
WEEKDAY_LABELS = [
'', // index 0 (unused)
'周一', // index 1 → dayOfWeek 1 (Monday)
'周二', // index 2 → dayOfWeek 2
'周三', // ... etc
'周四',
'周五',
'周六',
'周日' // index 7 → dayOfWeek 7 (Sunday)
]
```
---
## 🐛 Common Gotchas
### 1. dayOfWeek vs JS getDay()
- **Frontend uses**: ISO weekday (1=Mon, 7=Sun)
- **JS Date.getDay()**: 0=Sun, 6=Sat
- **Backend converts**: `toIsoWeekday()` in slot-generator.service.ts
### 2. Template Replace (Not Merge)
- `PUT /admin/week-template` **deletes all** and creates new
- NOT a merge/patch operation
- Frontend must send complete array
### 3. isDirty Flag
- Tracks **any** change locally (add/edit/delete/toggle)
- Used to show/hide save bar
- Cleared after successful save
### 4. Timezone
- All dates stored as UTC midnight: `setUTCHours(0,0,0,0)`
- Frontend displays as local YYYY-MM-DD strings
- May cause off-by-one on day boundaries
### 5. Slot Generation
- Uses `skipDuplicates: true` in Prisma
- Safe to re-run without creating duplicates
- Assumes `date + startTime + endTime` is unique
---
## 💡 Usage Example: Add a Monday 9AM Class
**Frontend (week-template.vue)**:
```typescript
// User clicks "+ 新增时段"
openAdd()
form.value = { dayIdx: 0, startTime: '09:00', endTime: '10:00', capacityStr: '10' }
showModal.value = true
// User confirms in modal
submitForm()
templates.value.push({
_key: String(Date.now()),
dayOfWeek: 1, // dayOptions[0].value = Monday
startTime: '09:00',
endTime: '10:00',
capacity: 10,
isActive: true
})
isDirty.value = true // ← Save bar appears
// User taps "保存全部更改"
handleSave()
payload = templates.value.map(t => ({...}))
await adminStore.saveWeekTemplates(payload)
// Backend creates transaction:
// DELETE FROM week_template
// INSERT INTO week_template (day_of_week, start_time, end_time, capacity, is_active)
// VALUES (1, '09:00', '10:00', 10, true)
// ... (all other templates)
// Frontend refetches and displays
```
---
## 🔗 Related Components
- **Admin Members** (`pages/admin/members.vue`): Shows member list
- **Admin Orders** (`pages/admin/orders.vue`): Shows order history
- **Admin Card Types** (`pages/admin/card-types.vue`): Manage membership cards
- **Admin Studio** (`pages/admin/studio.vue`): Studio info settings
---
## 📈 Scalability Notes
### Current Approach
- Templates: Small dataset (typically < 50 records)
- Slots: Generated in batches (14 days at a time)
- Uses `skipDuplicates` to handle reruns safely
### Bottlenecks
- Template replacement deletes ALL and creates NEW (atomic but slow with 1000s)
- Slot generation is serial (could be parallelized)
- No pagination for templates (assumes all fit in memory)
### Future Improvements
- Batch template updates (don't replace all)
- Pagination if templates > 100
- Incremental slot generation (detect last generated date)

View File

@@ -1,18 +1,14 @@
<template>
<view class="brand-banner">
<!-- Background image layer -->
<!-- Background image layer with blur -->
<image
v-if="studioInfo?.bannerUrl"
class="banner-bg"
:src="studioInfo.bannerUrl"
:src="bannerImage"
mode="aspectFill"
/>
<!-- Dark overlay for readability -->
<view class="banner-overlay" />
<!-- Status bar spacer -->
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }" />
<!-- Centered content -->
<view class="banner-content">
<!-- Circular logo -->
@@ -33,19 +29,13 @@
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import type { StudioConfig } from '@mp-pilates/shared'
import { getSystemLayout } from '../utils/system'
defineProps<{
studioInfo: StudioConfig | null
}>()
const statusBarHeight = ref(0)
onMounted(() => {
statusBarHeight.value = getSystemLayout().statusBarHeight
})
const bannerImage = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/images/bannerBg.jpg'
</script>
<style lang="scss" scoped>
@@ -63,6 +53,8 @@ onMounted(() => {
left: 0;
width: 100%;
height: 100%;
filter: blur(2px);
transform: scale(1.05);
}
.banner-overlay {
@@ -74,11 +66,6 @@ onMounted(() => {
background: rgba($primary-dark, 0.25);
}
.status-bar {
position: relative;
z-index: 2;
}
.banner-content {
position: relative;
z-index: 2;
@@ -86,7 +73,7 @@ onMounted(() => {
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 40rpx;
padding-top: 120rpx;
}
.logo-circle {

View File

@@ -13,7 +13,7 @@
:key="i"
class="card-row skeleton-row"
>
<view class="skeleton-thumb" />
<view class="skeleton-card-cover" />
<view class="skeleton-info">
<view class="skeleton-line skeleton-line--title" />
<view class="skeleton-line skeleton-line--sub" />
@@ -30,30 +30,62 @@
class="card-row"
@tap="goToDetail(card.id)"
>
<!-- Thumbnail -->
<view class="card-thumb" :class="thumbClass(card)">
<view class="thumb-fallback">
<text class="thumb-name">{{ truncate(card.name, 8) }}</text>
<text class="thumb-price">¥{{ formatPrice(card.price) }}</text>
</view>
</view>
<!-- Card Cover horizontal premium design -->
<view class="card-cover" :class="getCardCoverClass(card.type)">
<!-- Left accent bar -->
<view class="cover-accent-bar" />
<!-- Card info -->
<view class="card-info">
<text class="card-name">{{ card.name }}</text>
<text class="card-validity">有效期:{{ card.durationDays }} </text>
<view class="price-row">
<text class="price-label">价格:</text>
<text class="price-symbol">¥</text>
<text class="price-current">{{ formatPrice(card.price) }}</text>
<!-- Decorative circles -->
<view class="cover-deco cover-deco--tl" />
<view class="cover-deco cover-deco--br" />
<!-- CSS Icon -->
<view class="cover-icon" :class="`cover-icon--${card.type}`" />
<!-- Right side: text content -->
<view class="cover-content">
<view class="cover-badge">
<text class="cover-badge-text">{{ getCardTypeLabel(card.type) }}</text>
</view>
<text class="cover-name">{{ card.name }}</text>
<view class="cover-price-row">
<text class="cover-currency">¥</text>
<text class="cover-price">{{ formatPrice(card.price) }}</text>
</view>
<text
v-if="card.originalPrice && card.originalPrice > card.price"
class="price-original"
class="cover-original"
>
原价:¥{{ formatPrice(card.originalPrice) }}
¥{{ formatPrice(card.originalPrice) }}
</text>
</view>
</view>
<!-- Card info aligns with card-cover height -->
<view class="card-info">
<view class="info-top">
<text class="card-name">{{ card.name }}</text>
<text class="card-validity">有效期 {{ card.durationDays }} </text>
</view>
<view class="info-bottom">
<view v-if="card.totalTimes" class="card-times">
<text class="card-times-value">{{ card.totalTimes }}</text>
<text class="card-times-unit">课时</text>
</view>
<view class="price-row">
<text class="price-current">¥{{ formatPrice(card.price) }}</text>
<text
v-if="card.originalPrice && card.originalPrice > card.price"
class="price-original"
>
¥{{ formatPrice(card.originalPrice) }}
</text>
</view>
</view>
</view>
<!-- Arrow -->
<text class="card-arrow"></text>
</view>
</view>
@@ -67,9 +99,8 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import type { CardType } from '@mp-pilates/shared'
import { CardTypeCategory } from '@mp-pilates/shared'
import { get } from '../utils/request'
import { formatPrice } from '../utils/format'
import { formatPrice, getCardTypeLabel, getCardCoverClass } from '../utils/format'
const cardTypes = ref<CardType[]>([])
const loading = ref(false)
@@ -98,19 +129,8 @@ function goToDetail(id: string) {
}
function goToAllCards() {
// Navigate to all cards page or scroll behavior
uni.navigateTo({ url: '/pages/card/detail?showAll=1' })
}
function thumbClass(card: CardType): string {
if (card.type === CardTypeCategory.TRIAL) return 'thumb--trial'
if (card.type === CardTypeCategory.DURATION) return 'thumb--duration'
return 'thumb--times'
}
function truncate(str: string, maxLen: number): string {
return str.length > maxLen ? str.slice(0, maxLen) + '…' : str
}
</script>
<style lang="scss" scoped>
@@ -150,132 +170,363 @@ function truncate(str: string, maxLen: number): string {
.card-row {
display: flex;
align-items: center;
gap: 24rpx;
padding: 24rpx 0;
border-bottom: 1rpx solid #f0f0f0;
gap: 20rpx;
padding: 16rpx 0;
border-bottom: 1rpx solid rgba($brand-color, 0.08);
&:last-child {
border-bottom: none;
}
}
/* ── Thumbnail ── */
.card-thumb {
width: 200rpx;
height: 140rpx;
border-radius: 12rpx;
/* ══════════════════════════════════════════════════════════
CARD COVER — Horizontal premium card design
══════════════════════════════════════════════════════════ */
.card-cover {
width: 240rpx;
height: 130rpx;
border-radius: 16rpx;
overflow: hidden;
flex-shrink: 0;
position: relative;
}
.thumb-fallback {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 8rpx;
padding: 12rpx;
gap: 0;
/* Glow effect behind */
&::before {
content: '';
position: absolute;
top: -20rpx;
left: -20rpx;
right: -20rpx;
bottom: -20rpx;
background: inherit;
filter: blur(24rpx) brightness(0.8);
z-index: 0;
opacity: 0.4;
}
}
.thumb--times .thumb-fallback {
background: linear-gradient(135deg, #3a3a3a, #555);
/* Left accent stripe */
.cover-accent-bar {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 6rpx;
background: rgba(255, 255, 255, 0.4);
z-index: 1;
}
.thumb--duration .thumb-fallback {
background: linear-gradient(135deg, #6c3483, #9b59b6);
/* Decorative circles */
.cover-deco {
position: absolute;
border-radius: 50%;
z-index: 0;
pointer-events: none;
&--tl {
width: 60rpx;
height: 60rpx;
top: -16rpx;
right: 20rpx;
background: rgba(255, 255, 255, 0.1);
}
&--br {
width: 80rpx;
height: 80rpx;
bottom: -24rpx;
left: -16rpx;
background: rgba(255, 255, 255, 0.07);
}
}
.thumb--trial .thumb-fallback {
background: linear-gradient(135deg, #5a7a8a, $primary-dark);
/* CSS-drawn icons */
.cover-icon {
width: 52rpx;
height: 52rpx;
position: relative;
z-index: 2;
flex-shrink: 0;
margin-left: 20rpx;
}
.thumb-name {
font-size: 22rpx;
/* 次卡 — stacked cards */
.cover-icon--TIMES {
&::before {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 36rpx;
height: 24rpx;
border: 2rpx solid rgba(255, 255, 255, 0.85);
border-radius: 5rpx;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.12);
}
&::after {
content: '';
position: absolute;
bottom: 10rpx;
left: 50%;
transform: translateX(-50%);
width: 36rpx;
height: 24rpx;
border: 2rpx solid rgba(255, 255, 255, 1);
border-radius: 5rpx;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.2);
}
}
/* 月卡 — calendar */
.cover-icon--DURATION {
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 36rpx;
height: 30rpx;
border: 2rpx solid rgba(255, 255, 255, 0.9);
border-radius: 5rpx;
box-sizing: border-box;
}
&::after {
content: '';
position: absolute;
top: 9rpx;
left: 50%;
transform: translateX(-50%);
width: 24rpx;
height: 0;
border-top: 2rpx solid rgba(255, 255, 255, 1);
box-shadow:
-6rpx 5rpx 0 0 rgba(255, 255, 255, 0.9),
6rpx 5rpx 0 0 rgba(255, 255, 255, 0.9);
}
}
/* 体验卡 — star */
.cover-icon--TRIAL {
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 16rpx;
height: 16rpx;
border: 2rpx solid rgba(255, 255, 255, 1);
border-radius: 50%;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.25);
}
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 2rpx;
height: 42rpx;
background: rgba(255, 255, 255, 0.8);
box-shadow:
0 -12rpx 0 0 rgba(255, 255, 255, 0.8),
0 12rpx 0 0 rgba(255, 255, 255, 0.8),
-12rpx 0 0 0 rgba(255, 255, 255, 0.8),
12rpx 0 0 0 rgba(255, 255, 255, 0.8),
-8rpx -8rpx 0 0 rgba(255, 255, 255, 0.8),
8rpx -8rpx 0 0 rgba(255, 255, 255, 0.8),
-8rpx 8rpx 0 0 rgba(255, 255, 255, 0.8),
8rpx 8rpx 0 0 rgba(255, 255, 255, 0.8);
}
}
/* Card cover backgrounds */
.cover--times {
background: linear-gradient(135deg, #1e2340 0%, #2d2d5e 50%, #3a3a7a 100%);
}
.cover--duration {
background: linear-gradient(135deg, #4a1a6b 0%, #6c3483 50%, #8e4aaf 100%);
}
.cover--trial {
background: linear-gradient(135deg, #14527a 0%, #1a6fa0 50%, #48a9a6 100%);
}
/* Right side text content */
.cover-content {
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
padding: 0 16rpx 0 12rpx;
gap: 4rpx;
z-index: 2;
}
.cover-badge {
padding: 3rpx 10rpx;
border-radius: 8rpx;
background: rgba(255, 255, 255, 0.18);
border: 1rpx solid rgba(255, 255, 255, 0.28);
}
.cover-badge-text {
font-size: 16rpx;
color: rgba(255, 255, 255, 0.9);
font-weight: 600;
color: #ffffff;
text-align: center;
line-height: 1.3;
word-break: break-all;
}
.thumb-price {
.cover-name {
font-size: 24rpx;
font-weight: 700;
color: #ffffff;
}
/* ── Card info ── */
.card-info {
flex: 1;
min-width: 0;
}
.card-name {
display: block;
font-size: 30rpx;
font-weight: 600;
color: #222;
margin-bottom: 8rpx;
letter-spacing: 0.5rpx;
line-height: 1.2;
max-width: 130rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-validity {
display: block;
font-size: 24rpx;
color: #999;
margin-bottom: 12rpx;
.cover-price-row {
display: flex;
align-items: baseline;
gap: 2rpx;
}
.price-row {
.cover-currency {
font-size: 18rpx;
font-weight: 600;
color: rgba(255, 255, 255, 0.85);
}
.cover-price {
font-size: 28rpx;
font-weight: 800;
color: #ffffff;
line-height: 1;
}
.cover-original {
font-size: 16rpx;
color: rgba(255, 255, 255, 0.5);
text-decoration: line-through;
}
/* ── Card info — matches card-cover height ── */
.card-info {
flex: 1;
min-width: 0;
height: 130rpx;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.info-top {
display: flex;
flex-direction: column;
justify-content: center;
gap: 4rpx;
}
.info-bottom {
display: flex;
align-items: baseline;
gap: 16rpx;
}
.card-name {
font-size: 30rpx;
font-weight: 700;
color: $text-primary;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
letter-spacing: 0.5rpx;
line-height: 1.2;
}
.card-validity {
font-size: 23rpx;
color: $text-secondary;
line-height: 1.2;
}
.card-times {
display: flex;
align-items: baseline;
gap: 4rpx;
}
.price-label {
font-size: 24rpx;
color: #e53935;
.card-times-value {
font-size: 34rpx;
font-weight: 800;
color: $brand-color;
line-height: 1;
}
.price-symbol {
font-size: 24rpx;
color: #e53935;
font-weight: 600;
.card-times-unit {
font-size: 20rpx;
color: $text-secondary;
font-weight: 500;
}
.price-row {
display: flex;
align-items: baseline;
gap: 6rpx;
}
.price-current {
font-size: 40rpx;
font-size: 32rpx;
font-weight: 800;
color: #e53935;
color: $brand-color;
line-height: 1;
}
.price-original {
font-size: 22rpx;
color: #bbb;
font-size: 20rpx;
color: $text-hint;
text-decoration: line-through;
margin-left: 12rpx;
}
/* Arrow */
.card-arrow {
font-size: 44rpx;
color: $text-hint;
flex-shrink: 0;
transform: scaleX(0.5);
transform-origin: center;
}
/* ── Skeleton ── */
.skeleton-row {
display: flex;
align-items: center;
gap: 24rpx;
padding: 24rpx 0;
border-bottom: 1rpx solid #f0f0f0;
gap: 20rpx;
padding: 20rpx 0;
border-bottom: 1rpx solid rgba($brand-color, 0.08);
}
.skeleton-thumb {
width: 200rpx;
height: 140rpx;
border-radius: 12rpx;
.skeleton-card-cover {
width: 240rpx;
height: 130rpx;
border-radius: 16rpx;
flex-shrink: 0;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
@@ -290,12 +541,12 @@ function truncate(str: string, maxLen: number): string {
.skeleton-line {
height: 24rpx;
border-radius: 6rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
&--title {
width: 70%;
width: 60%;
height: 30rpx;
}
@@ -304,7 +555,7 @@ function truncate(str: string, maxLen: number): string {
}
&--price {
width: 50%;
width: 45%;
height: 36rpx;
}
}
@@ -319,6 +570,6 @@ function truncate(str: string, maxLen: number): string {
.empty-text {
font-size: 28rpx;
color: #bbb;
color: $text-hint;
}
</style>

View File

@@ -8,17 +8,17 @@
</view>
</scroll-view>
<!-- Address + Phone row -->
<!-- Address + Chat row -->
<view class="location-row">
<view class="location-left" @tap="handleAddressTap">
<text class="location-icon">📍</text>
<view class="location-icon" />
<text class="location-text">
{{ studioInfo?.address || '深圳市宝安区西乡街道财富港 D 座 1203D' }}
</text>
</view>
<view class="phone-btn" @tap="handlePhoneTap">
<text class="phone-icon">📞</text>
</view>
<button class="chat-btn" open-type="contact">
<view class="chat-icon" />
</button>
</view>
</view>
</template>
@@ -39,23 +39,20 @@ function previewPhoto(index: number) {
}
function handleAddressTap() {
if (!props.studioInfo) return
const latitude = props.studioInfo?.latitude ?? 22.567048
const longitude = props.studioInfo?.longitude ?? 113.867227
const address = props.studioInfo?.address || '深圳市宝安区西乡街道财富港 D 座 1203D'
const name = props.studioInfo?.name || 'Focus Core'
const { latitude, longitude, address, name } = props.studioInfo
if (latitude && longitude) {
uni.openLocation({
latitude,
longitude,
name: name || 'Focus Core',
address,
fail() {
copyAddress()
},
})
} else {
copyAddress()
}
uni.openLocation({
latitude,
longitude,
name,
address,
fail() {
copyAddress()
},
})
}
function copyAddress() {
@@ -68,17 +65,6 @@ function copyAddress() {
},
})
}
function handlePhoneTap() {
const phone = props.studioInfo?.phone
if (!phone) return
uni.makePhoneCall({
phoneNumber: phone,
fail() {
uni.showToast({ title: '拨号失败', icon: 'none' })
},
})
}
</script>
<style lang="scss" scoped>
@@ -124,11 +110,6 @@ function handlePhoneTap() {
min-width: 0;
}
.location-icon {
font-size: 28rpx;
flex-shrink: 0;
}
.location-text {
font-size: 26rpx;
color: #666;
@@ -136,19 +117,94 @@ function handlePhoneTap() {
word-break: break-all;
}
.phone-btn {
/* ── Icons ── */
.location-icon {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: rgba($brand-color, 0.06);
position: relative;
flex-shrink: 0;
// 定位图标 — 圆头 + 尖尾
&::before {
content: '';
position: absolute;
top: 14rpx;
left: 50%;
transform: translateX(-50%);
width: 18rpx;
height: 18rpx;
border: 2.5rpx solid $brand-color;
border-radius: 50% 50% 50% 0;
transform: translateX(-50%) rotate(-45deg);
box-sizing: border-box;
}
// 中心白点
&::after {
content: '';
position: absolute;
top: 21rpx;
left: 50%;
transform: translateX(-50%);
width: 6rpx;
height: 6rpx;
background: $brand-color;
border-radius: 50%;
box-sizing: border-box;
}
}
.chat-btn {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: #f5f5f5;
background: rgba($brand-color, 0.06);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 0;
margin: 0;
border: none;
}
.phone-icon {
font-size: 36rpx;
color: #4CAF50;
.chat-btn::after {
border: none;
}
.chat-icon {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: rgba($brand-color, 0.06);
position: relative;
// 消息气泡
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 26rpx;
height: 20rpx;
border: 2.5rpx solid $brand-color;
border-radius: 6rpx;
box-sizing: border-box;
}
// 气泡尾巴
&::after {
content: '';
position: absolute;
bottom: 12rpx;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 5rpx solid transparent;
border-right: 5rpx solid transparent;
border-top: 7rpx solid $brand-color;
}
}
</style>

View File

@@ -37,25 +37,54 @@
class="card-row"
@tap="goToDetail(c.id)"
>
<view class="card-thumb" :class="thumbClass(c)">
<view class="thumb-fallback">
<text class="thumb-name">{{ truncate(c.name, 8) }}</text>
<text class="thumb-price">¥{{ formatPrice(c.price) }}</text>
</view>
</view>
<view class="card-info">
<text class="card-name">{{ c.name }}</text>
<text class="card-validity">有效期:{{ c.durationDays }} </text>
<view class="price-row">
<text class="price-current">¥{{ formatPrice(c.price) }}</text>
<!-- Card Cover horizontal -->
<view class="card-cover" :class="getCardCoverClass(c.type)">
<view class="cover-accent-bar" />
<view class="cover-deco cover-deco--tl" />
<view class="cover-deco cover-deco--br" />
<view class="cover-icon" :class="`cover-icon--${c.type}`" />
<view class="cover-content">
<view class="cover-badge">
<text class="cover-badge-text">{{ getCardTypeLabel(c.type) }}</text>
</view>
<text class="cover-name">{{ c.name }}</text>
<view class="cover-price-row">
<text class="cover-currency">¥</text>
<text class="cover-price">{{ formatPrice(c.price) }}</text>
</view>
<text
v-if="c.originalPrice && c.originalPrice > c.price"
class="price-original"
class="cover-original"
>
原价:¥{{ formatPrice(c.originalPrice) }}
¥{{ formatPrice(c.originalPrice) }}
</text>
</view>
</view>
<!-- Card info aligns with card-cover height -->
<view class="card-info">
<view class="info-top">
<text class="card-name">{{ c.name }}</text>
<text class="card-validity">有效期 {{ c.durationDays }} </text>
</view>
<view class="info-bottom">
<view v-if="c.totalTimes" class="card-times">
<text class="card-times-value">{{ c.totalTimes }}</text>
<text class="card-times-unit">课时</text>
</view>
<view class="price-row">
<text class="price-current">¥{{ formatPrice(c.price) }}</text>
<text
v-if="c.originalPrice && c.originalPrice > c.price"
class="price-original"
>
¥{{ formatPrice(c.originalPrice) }}
</text>
</view>
</view>
</view>
<text class="card-arrow"></text>
</view>
</view>
<view v-else class="empty-state">
@@ -172,7 +201,7 @@ import { ref, computed, onMounted } from 'vue'
import type { CardType, CreateOrderResponse } from '@mp-pilates/shared'
import { CardTypeCategory } from '@mp-pilates/shared'
import { get, post } from '../../utils/request'
import { formatPrice } from '../../utils/format'
import { formatPrice, getCardTypeLabel, getCardCoverClass } from '../../utils/format'
import { getSystemLayout } from '../../utils/system'
import { useUserStore } from '../../stores/user'
import CustomNavBar from '../../components/CustomNavBar.vue'
@@ -257,16 +286,6 @@ function goToDetail(id: string) {
uni.navigateTo({ url: `/pages/card/detail?id=${id}` })
}
function thumbClass(card: CardType): string {
if (card.type === CardTypeCategory.TRIAL) return 'thumb--trial'
if (card.type === CardTypeCategory.DURATION) return 'thumb--duration'
return 'thumb--times'
}
function truncate(str: string, maxLen: number): string {
return str.length > maxLen ? str.slice(0, maxLen) + '…' : str
}
// ─── Buy flow ─────────────────────────────────────────────
async function handleBuy() {
if (buying.value || !card.value) return
@@ -710,101 +729,335 @@ onMounted(() => {
.card-row {
display: flex;
align-items: center;
gap: 24rpx;
padding: 24rpx;
gap: 20rpx;
padding: 20rpx;
background: #fff;
border-radius: 20rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
}
.card-thumb {
width: 200rpx;
height: 140rpx;
border-radius: 12rpx;
/* ══════════════════════════════════════════════════════════
CARD COVER — Horizontal premium card design
══════════════════════════════════════════════════════════ */
.card-cover {
width: 240rpx;
height: 130rpx;
border-radius: 16rpx;
overflow: hidden;
flex-shrink: 0;
position: relative;
}
.thumb-fallback {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 8rpx;
padding: 12rpx;
&::before {
content: '';
position: absolute;
top: -20rpx;
left: -20rpx;
right: -20rpx;
bottom: -20rpx;
background: inherit;
filter: blur(24rpx) brightness(0.8);
z-index: 0;
opacity: 0.4;
}
}
.thumb--times .thumb-fallback {
background: linear-gradient(135deg, #3a3a3a, #555);
.cover-accent-bar {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 6rpx;
background: rgba(255, 255, 255, 0.4);
z-index: 1;
}
.thumb--duration .thumb-fallback {
background: linear-gradient(135deg, #6c3483, #9b59b6);
.cover-deco {
position: absolute;
border-radius: 50%;
z-index: 0;
pointer-events: none;
&--tl {
width: 60rpx;
height: 60rpx;
top: -16rpx;
right: 20rpx;
background: rgba(255, 255, 255, 0.1);
}
&--br {
width: 80rpx;
height: 80rpx;
bottom: -24rpx;
left: -16rpx;
background: rgba(255, 255, 255, 0.07);
}
}
.thumb--trial .thumb-fallback {
background: linear-gradient(135deg, #5a7a8a, $primary-dark);
.cover-icon {
width: 52rpx;
height: 52rpx;
position: relative;
z-index: 2;
flex-shrink: 0;
margin-left: 20rpx;
}
.thumb-name {
font-size: 22rpx;
.cover-icon--TIMES {
&::before {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 36rpx;
height: 24rpx;
border: 2rpx solid rgba(255, 255, 255, 0.85);
border-radius: 5rpx;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.12);
}
&::after {
content: '';
position: absolute;
bottom: 10rpx;
left: 50%;
transform: translateX(-50%);
width: 36rpx;
height: 24rpx;
border: 2rpx solid rgba(255, 255, 255, 1);
border-radius: 5rpx;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.2);
}
}
.cover-icon--DURATION {
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 36rpx;
height: 30rpx;
border: 2rpx solid rgba(255, 255, 255, 0.9);
border-radius: 5rpx;
box-sizing: border-box;
}
&::after {
content: '';
position: absolute;
top: 9rpx;
left: 50%;
transform: translateX(-50%);
width: 24rpx;
height: 0;
border-top: 2rpx solid rgba(255, 255, 255, 1);
box-shadow:
-6rpx 5rpx 0 0 rgba(255, 255, 255, 0.9),
6rpx 5rpx 0 0 rgba(255, 255, 255, 0.9);
}
}
.cover-icon--TRIAL {
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 16rpx;
height: 16rpx;
border: 2rpx solid rgba(255, 255, 255, 1);
border-radius: 50%;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.25);
}
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 2rpx;
height: 42rpx;
background: rgba(255, 255, 255, 0.8);
box-shadow:
0 -12rpx 0 0 rgba(255, 255, 255, 0.8),
0 12rpx 0 0 rgba(255, 255, 255, 0.8),
-12rpx 0 0 0 rgba(255, 255, 255, 0.8),
12rpx 0 0 0 rgba(255, 255, 255, 0.8),
-8rpx -8rpx 0 0 rgba(255, 255, 255, 0.8),
8rpx -8rpx 0 0 rgba(255, 255, 255, 0.8),
-8rpx 8rpx 0 0 rgba(255, 255, 255, 0.8),
8rpx 8rpx 0 0 rgba(255, 255, 255, 0.8);
}
}
.cover--times {
background: linear-gradient(135deg, #1e2340 0%, #2d2d5e 50%, #3a3a7a 100%);
}
.cover--duration {
background: linear-gradient(135deg, #4a1a6b 0%, #6c3483 50%, #8e4aaf 100%);
}
.cover--trial {
background: linear-gradient(135deg, #14527a 0%, #1a6fa0 50%, #48a9a6 100%);
}
.cover-content {
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
padding: 0 16rpx 0 12rpx;
gap: 4rpx;
z-index: 2;
}
.cover-badge {
padding: 3rpx 10rpx;
border-radius: 8rpx;
background: rgba(255, 255, 255, 0.18);
border: 1rpx solid rgba(255, 255, 255, 0.28);
}
.cover-badge-text {
font-size: 16rpx;
color: rgba(255, 255, 255, 0.9);
font-weight: 600;
color: #ffffff;
text-align: center;
line-height: 1.3;
word-break: break-all;
}
.thumb-price {
.cover-name {
font-size: 24rpx;
font-weight: 700;
color: #ffffff;
}
.card-info {
flex: 1;
min-width: 0;
}
.card-name {
display: block;
font-size: 30rpx;
font-weight: 600;
color: #222;
margin-bottom: 8rpx;
letter-spacing: 0.5rpx;
line-height: 1.2;
max-width: 130rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cover-price-row {
display: flex;
align-items: baseline;
gap: 2rpx;
}
.cover-currency {
font-size: 18rpx;
font-weight: 600;
color: rgba(255, 255, 255, 0.85);
}
.cover-price {
font-size: 28rpx;
font-weight: 800;
color: #ffffff;
line-height: 1;
}
.cover-original {
font-size: 16rpx;
color: rgba(255, 255, 255, 0.5);
text-decoration: line-through;
}
/* ── Card info — matches card-cover height ── */
.card-info {
flex: 1;
min-width: 0;
height: 130rpx;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.info-top {
display: flex;
flex-direction: column;
justify-content: center;
gap: 4rpx;
}
.info-bottom {
display: flex;
align-items: baseline;
gap: 16rpx;
}
.card-name {
font-size: 30rpx;
font-weight: 700;
color: $text-primary;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
letter-spacing: 0.5rpx;
line-height: 1.2;
}
.card-validity {
display: block;
font-size: 24rpx;
color: #999;
margin-bottom: 12rpx;
font-size: 23rpx;
color: $text-secondary;
line-height: 1.2;
}
.card-times {
display: flex;
align-items: baseline;
gap: 4rpx;
}
.card-times-value {
font-size: 34rpx;
font-weight: 800;
color: $brand-color;
line-height: 1;
}
.card-times-unit {
font-size: 20rpx;
color: $text-secondary;
font-weight: 500;
}
.price-row {
display: flex;
align-items: baseline;
gap: 8rpx;
gap: 6rpx;
}
.price-current {
font-size: 40rpx;
font-size: 32rpx;
font-weight: 800;
color: #e53935;
color: $brand-color;
line-height: 1;
}
.price-original {
font-size: 22rpx;
color: #bbb;
font-size: 20rpx;
color: $text-hint;
text-decoration: line-through;
}
.card-arrow {
font-size: 44rpx;
color: $text-hint;
flex-shrink: 0;
transform: scaleX(0.5);
transform-origin: center;
}
/* ── Empty state ─────────────────────────────────────── */
.empty-state {
padding: 160rpx 40rpx;

View File

@@ -1,12 +1,11 @@
<template>
<view class="home-page" :style="pageStyle">
<!-- Custom nav bar -->
<CustomNavBar title="场馆首页" />
<view class="home-page">
<!-- Pull-to-refresh wrapper -->
<scroll-view
class="page-scroll"
scroll-y
:scroll-top="scrollTop"
:scroll-with-animation="true"
:refresher-enabled="true"
:refresher-triggered="refreshing"
@refresherrefresh="handleRefresh"
@@ -42,7 +41,6 @@
import { ref, computed } from 'vue'
import { onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import BrandBanner from '../../components/BrandBanner.vue'
import StudioInfo from '../../components/StudioInfo.vue'
import QuickEntry from '../../components/QuickEntry.vue'
@@ -52,7 +50,6 @@ import CardShop from '../../components/CardShop.vue'
import { useUserStore } from '../../stores/user'
import { useStudioStore } from '../../stores/studio'
import { useBookingStore } from '../../stores/booking'
import { getSystemLayout } from '../../utils/system'
const userStore = useUserStore()
const studioStore = useStudioStore()
@@ -75,24 +72,10 @@ onShareTimeline(() => {
})
// ─── Layout ───────────────────────────────────────────────
const navBarHeight = ref('64px')
function updateLayout() {
const { statusBarHeight: statusBarPx, windowWidth, navBarHeight: navBarPx } = getSystemLayout()
const ratio = windowWidth / 750
const navTitlePx = 88 * ratio
navBarHeight.value = `${navBarPx}px`
}
updateLayout()
const pageStyle = computed(() => ({
'--nav-bar-height': navBarHeight.value,
}))
const refreshing = ref(false)
const cardShopRef = ref<InstanceType<typeof CardShop> | null>(null)
const cardShopAnchorId = 'card-shop-anchor'
const scrollTop = ref(0)
// Refresh all data on every show
onShow(async () => {
@@ -125,22 +108,28 @@ async function handleRefresh() {
}
function scrollToCardShop() {
uni.pageScrollTo({
selector: `#${cardShopAnchorId}`,
duration: 300,
})
uni.createSelectorQuery()
.select(`#${cardShopAnchorId}`)
.boundingClientRect((rect) => {
if (rect) {
scrollTop.value = rect.top
}
})
.exec()
}
</script>
<style lang="scss" scoped>
.home-page {
min-height: 100vh;
display: flex;
flex-direction: column;
height: 100vh;
background: #FAF8F5;
padding-top: var(--nav-bar-height);
}
.page-scroll {
height: calc(100vh - var(--nav-bar-height));
flex: 1;
overflow-y: auto;
}
.section-divider {

View File

@@ -1,3 +1,6 @@
import type { CardType } from '@mp-pilates/shared'
import { CardTypeCategory } from '@mp-pilates/shared'
/** 格式化金额:分 → 元 */
export function formatPrice(cents: number): string {
return (cents / 100).toFixed(2)
@@ -44,3 +47,20 @@ export function getDateRange(days: number): ReadonlyArray<{ readonly date: strin
}
return result
}
/** 会员卡类型标签 */
export function getCardTypeLabel(type: CardTypeCategory): string {
const map: Record<CardTypeCategory, string> = {
[CardTypeCategory.TIMES]: '次卡',
[CardTypeCategory.DURATION]: '月卡',
[CardTypeCategory.TRIAL]: '体验',
}
return map[type] ?? '会员'
}
/** 会员卡封面 CSS 类名 */
export function getCardCoverClass(type: CardTypeCategory): string {
if (type === CardTypeCategory.TRIAL) return 'cover--trial'
if (type === CardTypeCategory.DURATION) return 'cover--duration'
return 'cover--times'
}