# 普拉提私教约课小程序 — 实施计划 > **Spec**: [2026-04-02-pilates-booking-miniprogram-design.md](../specs/2026-04-02-pilates-booking-miniprogram-design.md) > **Created**: 2026-04-02 --- ## Phase 1: Monorepo 基础设施 ### Task 1.1: 初始化 pnpm monorepo **Files to create/modify:** - `package.json` (root) - `pnpm-workspace.yaml` - `tsconfig.base.json` - `.gitignore` - `.nvmrc` - `.npmrc` **Steps:** 1. Create root `package.json`: ```json { "name": "mp-pilates", "private": true, "scripts": { "dev:server": "pnpm --filter @mp-pilates/server dev", "dev:app": "pnpm --filter @mp-pilates/app dev:mp-weixin", "build:shared": "pnpm --filter @mp-pilates/shared build", "build:server": "pnpm --filter @mp-pilates/server build", "build:app": "pnpm --filter @mp-pilates/app build:mp-weixin", "test": "pnpm -r test", "lint": "pnpm -r lint" }, "engines": { "node": ">=18", "pnpm": ">=8" } } ``` 2. Create `pnpm-workspace.yaml`: ```yaml packages: - 'packages/*' ``` 3. Create `tsconfig.base.json` with shared compiler options (strict, ESNext, paths for `@mp-pilates/shared`). 4. Create `.gitignore` (node_modules, dist, .env*, .DS_Store, *.local, dist-ssr). 5. Create `.nvmrc` with `20`. 6. Create `.npmrc` with `shamefully-hoist=true` (needed for uni-app). **Verify:** `pnpm install` runs without error from root. **Commit:** `chore: initialize pnpm monorepo structure` --- ### Task 1.2: 创建 shared 包 **Files to create:** - `packages/shared/package.json` - `packages/shared/tsconfig.json` - `packages/shared/src/index.ts` - `packages/shared/src/enums.ts` - `packages/shared/src/constants.ts` - `packages/shared/src/types/index.ts` - `packages/shared/src/types/user.ts` - `packages/shared/src/types/card-type.ts` - `packages/shared/src/types/membership.ts` - `packages/shared/src/types/time-slot.ts` - `packages/shared/src/types/booking.ts` - `packages/shared/src/types/order.ts` - `packages/shared/src/types/studio.ts` - `packages/shared/src/types/week-template.ts` - `packages/shared/src/types/api.ts` **Steps:** 1. Create `packages/shared/package.json`: ```json { "name": "@mp-pilates/shared", "version": "0.1.0", "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { "build": "tsc", "dev": "tsc --watch" } } ``` 2. Define enums in `src/enums.ts`: ```typescript export enum UserRole { MEMBER = 'MEMBER', ADMIN = 'ADMIN' } export enum CardTypeCategory { TIMES = 'TIMES', DURATION = 'DURATION', TRIAL = 'TRIAL' } export enum MembershipStatus { ACTIVE = 'ACTIVE', EXPIRED = 'EXPIRED', USED_UP = 'USED_UP' } export enum TimeSlotStatus { OPEN = 'OPEN', FULL = 'FULL', CLOSED = 'CLOSED' } export enum TimeSlotSource { TEMPLATE = 'TEMPLATE', MANUAL = 'MANUAL' } export enum BookingStatus { CONFIRMED = 'CONFIRMED', CANCELLED = 'CANCELLED', COMPLETED = 'COMPLETED', NO_SHOW = 'NO_SHOW' } export enum OrderStatus { PENDING = 'PENDING', PAID = 'PAID', REFUNDED = 'REFUNDED' } ``` 3. Define constants in `src/constants.ts`: ```typescript export const DEFAULT_CANCEL_HOURS_LIMIT = 2 export const DEFAULT_SLOT_CAPACITY = 1 export const SLOT_GENERATION_DAYS = 14 export const TIME_PERIODS = { MORNING: { start: '06:00', end: '12:00' }, AFTERNOON: { start: '12:00', end: '18:00' }, EVENING: { start: '18:00', end: '22:00' } } as const ``` 4. Define TypeScript interfaces for all data models in `src/types/` — one file per model, matching the spec's data model section. Each interface has both the full model (`User`) and creation DTO (`CreateUserDto`), response DTO (`UserResponse`). 5. Define API response types in `src/types/api.ts`: ```typescript export interface ApiResponse { readonly success: boolean readonly data: T | null readonly message: string | null } export interface PaginatedResponse extends ApiResponse { readonly total: number readonly page: number readonly limit: number } ``` 6. Re-export all from `src/index.ts`. **Verify:** `cd packages/shared && pnpm build` compiles without error and produces `dist/`. **Commit:** `feat(shared): add shared types, enums, and constants` --- ## Phase 2: 后端基础 ### Task 2.1: 初始化 NestJS 服务端 **Files to create:** - `packages/server/package.json` - `packages/server/tsconfig.json` - `packages/server/tsconfig.build.json` - `packages/server/nest-cli.json` - `packages/server/src/main.ts` - `packages/server/src/app.module.ts` - `packages/server/src/app.controller.ts` (health check) - `packages/server/.env.example` - `packages/server/.env` **Steps:** 1. Create `package.json` with dependencies: - `@nestjs/core`, `@nestjs/common`, `@nestjs/platform-express` - `@nestjs/config`, `@nestjs/jwt`, `@nestjs/schedule` - `@prisma/client`, `prisma` - `@mp-pilates/shared` (workspace reference) - Dev: `@nestjs/testing`, `jest`, `ts-jest`, `@types/jest`, `supertest` 2. Create NestJS bootstrap in `src/main.ts`: - Global validation pipe (`class-validator`, `class-transformer`) - CORS config - Global prefix `/api` - Port from env (default 3000) 3. Create `AppModule` with `ConfigModule.forRoot()`, health check endpoint. 4. Create `.env.example`: ``` DATABASE_URL=postgresql://user:pass@localhost:5432/mp_pilates JWT_SECRET=change-me WX_APPID=your-appid WX_SECRET=your-secret WX_MCH_ID=your-mch-id WX_MCH_KEY=your-mch-key PORT=3000 ``` **Verify:** `cd packages/server && pnpm dev` starts the server, `curl localhost:3000/api/health` returns 200. **Commit:** `feat(server): initialize NestJS project with health check` --- ### Task 2.2: Prisma schema 和数据库 **Files to create:** - `packages/server/prisma/schema.prisma` - `packages/server/src/prisma/prisma.module.ts` - `packages/server/src/prisma/prisma.service.ts` **Steps:** 1. Write `prisma/schema.prisma` with all 8 models from spec: ```prisma generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } enum UserRole { MEMBER ADMIN } enum CardTypeCategory { TIMES DURATION TRIAL } enum MembershipStatus { ACTIVE EXPIRED USED_UP } enum TimeSlotStatus { OPEN FULL CLOSED } enum TimeSlotSource { TEMPLATE MANUAL } enum BookingStatus { CONFIRMED CANCELLED COMPLETED NO_SHOW } enum OrderStatus { PENDING PAID REFUNDED } model User { id String @id @default(uuid()) openid String @unique unionid String? phone String? nickname String @default("") avatarUrl String? role UserRole @default(MEMBER) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt memberships Membership[] bookings Booking[] orders Order[] @@map("users") } model CardType { id String @id @default(uuid()) name String type CardTypeCategory totalTimes Int? durationDays Int price Decimal @db.Decimal(10, 0) originalPrice Decimal? @db.Decimal(10, 0) description String? isActive Boolean @default(true) sortOrder Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt memberships Membership[] orders Order[] @@map("card_types") } model Membership { id String @id @default(uuid()) userId String cardTypeId String remainingTimes Int? startDate DateTime expireDate DateTime status MembershipStatus @default(ACTIVE) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id]) cardType CardType @relation(fields: [cardTypeId], references: [id]) bookings Booking[] @@map("memberships") } model WeekTemplate { id String @id @default(uuid()) dayOfWeek Int startTime String endTime String capacity Int @default(1) isActive Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt timeSlots TimeSlot[] @@map("week_templates") } model TimeSlot { id String @id @default(uuid()) date DateTime @db.Date startTime String endTime String capacity Int @default(1) bookedCount Int @default(0) status TimeSlotStatus @default(OPEN) source TimeSlotSource @default(TEMPLATE) templateId String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt template WeekTemplate? @relation(fields: [templateId], references: [id]) bookings Booking[] @@unique([date, startTime, endTime]) @@map("time_slots") } model Booking { id String @id @default(uuid()) userId String timeSlotId String membershipId String status BookingStatus @default(CONFIRMED) cancelledAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id]) timeSlot TimeSlot @relation(fields: [timeSlotId], references: [id]) membership Membership @relation(fields: [membershipId], references: [id]) @@unique([userId, timeSlotId]) @@map("bookings") } model Order { id String @id @default(uuid()) userId String cardTypeId String orderNo String @unique amount Decimal @db.Decimal(10, 0) status OrderStatus @default(PENDING) wxTransactionId String? paidAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id]) cardType CardType @relation(fields: [cardTypeId], references: [id]) @@map("orders") } model StudioConfig { id String @id @default(uuid()) name String logo String? bannerUrl String? address String @default("") phone String @default("") latitude Decimal? @db.Decimal(10, 7) longitude Decimal? @db.Decimal(10, 7) cancelHoursLimit Int @default(2) photos Json @default("[]") updatedAt DateTime @updatedAt @@map("studio_config") } ``` 2. Create `PrismaService` extending `PrismaClient` with `onModuleInit` / `onModuleDestroy`. 3. Create `PrismaModule` as a global module. 4. Add Prisma scripts to server package.json: ```json "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate dev", "prisma:seed": "ts-node prisma/seed.ts" ``` **Verify:** ```bash cd packages/server pnpm prisma generate pnpm prisma migrate dev --name init ``` Database created with all tables. **Commit:** `feat(server): add Prisma schema with all data models` --- ### Task 2.3: 认证模块 (Auth) **Files to create:** - `packages/server/src/auth/auth.module.ts` - `packages/server/src/auth/auth.controller.ts` - `packages/server/src/auth/auth.service.ts` - `packages/server/src/auth/jwt.strategy.ts` - `packages/server/src/auth/jwt-auth.guard.ts` - `packages/server/src/auth/roles.guard.ts` - `packages/server/src/auth/roles.decorator.ts` - `packages/server/src/auth/dto/login.dto.ts` - `packages/server/src/auth/dto/bind-phone.dto.ts` - `packages/server/src/auth/wechat.service.ts` - `packages/server/src/auth/__tests__/auth.service.spec.ts` - `packages/server/src/auth/__tests__/auth.controller.spec.ts` **Steps:** 1. `WechatService`: encapsulates `code2session` API call to exchange wx code for openid/session_key. Mock-able for tests. 2. `AuthService`: - `login(code: string)`: call WechatService → find or create User → sign JWT with `{ sub: user.id, role: user.role }` - `bindPhone(userId: string, encryptedData: string, iv: string)`: decrypt phone → update user 3. `AuthController`: - `POST /auth/login` → `{ code }` → returns `{ token, user }` - `POST /auth/phone` → `{ encryptedData, iv }` → returns updated user (requires auth) 4. `JwtStrategy` + `JwtAuthGuard`: standard Passport JWT strategy. 5. `RolesGuard` + `@Roles()` decorator: check `user.role` against required roles. **Tests (TDD):** - Auth service: mock WechatService + PrismaService, test login creates user, returns JWT - Auth service: test login with existing user returns existing user - Auth controller: e2e test with supertest **Verify:** `cd packages/server && pnpm test -- --testPathPattern=auth` **Commit:** `feat(server): add auth module with WeChat login and JWT` --- ### Task 2.4: 用户模块 (User) **Files to create:** - `packages/server/src/user/user.module.ts` - `packages/server/src/user/user.controller.ts` - `packages/server/src/user/user.service.ts` - `packages/server/src/user/__tests__/user.service.spec.ts` **Steps:** 1. `UserService`: - `getProfile(userId)`: return user with active memberships count - `updateProfile(userId, dto)`: update nickname/avatarUrl (immutable — return new object) - `getStats(userId)`: query bookings for training stats (total count, this month count, total days, this month days, this month hours) 2. `UserController` (all routes require auth): - `GET /user/profile` - `PUT /user/profile` - `GET /user/stats` **Tests:** Mock Prisma, verify stats calculation, verify profile update returns new data. **Verify:** `pnpm test -- --testPathPattern=user` **Commit:** `feat(server): add user module with profile and stats` --- ### Task 2.5: 工作室信息模块 (Studio) **Files to create:** - `packages/server/src/studio/studio.module.ts` - `packages/server/src/studio/studio.controller.ts` - `packages/server/src/studio/studio.service.ts` - `packages/server/src/studio/__tests__/studio.service.spec.ts` - `packages/server/prisma/seed.ts` **Steps:** 1. `StudioService`: - `getInfo()`: return the single StudioConfig record (create default if not exists) - `updateInfo(dto)`: update studio config (ADMIN only) 2. `StudioController`: - `GET /studio/info` — public - `PUT /admin/studio/info` — ADMIN only 3. Seed script: create default StudioConfig record. **Tests:** Verify get creates default, verify update returns new record. **Verify:** `pnpm test -- --testPathPattern=studio` **Commit:** `feat(server): add studio module with config management` --- ### Task 2.6: 卡种与会员卡模块 (Membership) **Files to create:** - `packages/server/src/membership/membership.module.ts` - `packages/server/src/membership/membership.controller.ts` - `packages/server/src/membership/membership.service.ts` - `packages/server/src/membership/dto/create-card-type.dto.ts` - `packages/server/src/membership/dto/update-card-type.dto.ts` - `packages/server/src/membership/__tests__/membership.service.spec.ts` **Steps:** 1. `MembershipService`: - `getActiveCardTypes()`: list card types where `isActive=true`, ordered by `sortOrder` - `getUserMemberships(userId)`: user's memberships with cardType info - `getValidMembership(userId)`: find first ACTIVE membership with remaining times/valid date - `deductMembership(membershipId)`: decrement `remainingTimes` (TIMES/TRIAL) or noop (DURATION), mark USED_UP if times=0 - `restoreMembership(membershipId)`: increment `remainingTimes` for cancellation refund, restore status from USED_UP to ACTIVE - Admin CRUD for card types 2. `MembershipController`: - `GET /membership/card-types` — public - `GET /membership/my` — authed - `GET /admin/card-types`, `POST`, `PUT :id`, `DELETE :id` — ADMIN **Tests:** - deductMembership: times card decrements, used up marks status - deductMembership: duration card does nothing to times - restoreMembership: restores count and status - getValidMembership: returns first valid card, skips expired **Verify:** `pnpm test -- --testPathPattern=membership` **Commit:** `feat(server): add membership module with card types and deduction logic` --- ### Task 2.7: 时段与排课模块 (TimeSlot) **Files to create:** - `packages/server/src/time-slot/time-slot.module.ts` - `packages/server/src/time-slot/time-slot.controller.ts` - `packages/server/src/time-slot/time-slot.service.ts` - `packages/server/src/time-slot/slot-generator.service.ts` - `packages/server/src/time-slot/dto/query-slots.dto.ts` - `packages/server/src/time-slot/__tests__/time-slot.service.spec.ts` - `packages/server/src/time-slot/__tests__/slot-generator.service.spec.ts` **Steps:** 1. `TimeSlotService`: - `getAvailableSlots(date: string)`: query TimeSlots for date, include booking status for current user - `getSlotById(id)`: single slot with details - Admin operations: create manual slot, close slot 2. `SlotGeneratorService`: - `generateSlots(daysAhead: number = 14)`: read active WeekTemplates → generate TimeSlots for future dates, skip if `[date, startTime, endTime]` already exists - `cleanupExpiredSlots()`: mark past OPEN slots as CLOSED - `checkExpiredMemberships()`: mark expired memberships as EXPIRED, used-up as USED_UP 3. Admin controller endpoints: - `GET /admin/week-template` - `PUT /admin/week-template` (full replacement of all templates) - `POST /admin/time-slot/manual` - `PUT /admin/time-slot/:id/close` - `POST /admin/generate-slots` (manual trigger) **Tests:** - SlotGenerator: generates correct slots from templates - SlotGenerator: skips existing date+time combinations - SlotGenerator: marks past slots as CLOSED - TimeSlotService: returns slots with user booking status **Verify:** `pnpm test -- --testPathPattern=time-slot` **Commit:** `feat(server): add time-slot module with template-based generation` --- ### Task 2.8: 预约模块 (Booking) **Files to create:** - `packages/server/src/booking/booking.module.ts` - `packages/server/src/booking/booking.controller.ts` - `packages/server/src/booking/booking.service.ts` - `packages/server/src/booking/dto/create-booking.dto.ts` - `packages/server/src/booking/__tests__/booking.service.spec.ts` **Steps:** 1. `BookingService`: - `createBooking(userId, timeSlotId, membershipId)`: - Validate: slot is OPEN, not already booked by user, membership is valid - Transaction: create Booking, increment slot `bookedCount`, deduct membership - If `bookedCount >= capacity` → set slot status to FULL - `cancelBooking(userId, bookingId)`: - Check ownership, check booking status is CONFIRMED - Calculate if within `cancelHoursLimit` - Transaction: update booking to CANCELLED, decrement `bookedCount`, restore membership (if within limit), reset slot to OPEN if was FULL - `getMyBookings(userId, status?)`: paginated list - `getUpcomingBookings(userId)`: future confirmed bookings - Admin: `getAllBookings(filters)`: all bookings with user info 2. `BookingController`: - `POST /booking` — `{ timeSlotId, membershipId }` - `PUT /booking/:id/cancel` - `GET /booking/my` - `GET /booking/my/upcoming` - `GET /admin/bookings` — ADMIN **Tests (critical business logic):** - createBooking: success path — booking created, count incremented, membership deducted - createBooking: fail — slot full - createBooking: fail — duplicate booking - createBooking: fail — expired membership - cancelBooking: within limit — refunds membership - cancelBooking: past limit — no refund, still cancels - cancelBooking: slot status changes from FULL to OPEN **Verify:** `pnpm test -- --testPathPattern=booking` **Commit:** `feat(server): add booking module with reservation and cancellation logic` --- ### Task 2.9: 支付模块 (Payment) **Files to create:** - `packages/server/src/payment/payment.module.ts` - `packages/server/src/payment/payment.controller.ts` - `packages/server/src/payment/payment.service.ts` - `packages/server/src/payment/wechat-pay.service.ts` - `packages/server/src/payment/dto/create-order.dto.ts` - `packages/server/src/payment/__tests__/payment.service.spec.ts` **Steps:** 1. `WechatPayService`: encapsulate WeChat Pay unified order API, sign verification, callback parsing. Mock-able for tests. 2. `PaymentService`: - `createOrder(userId, cardTypeId)`: - Generate order number (timestamp + random) - Create Order (PENDING) - Call WechatPay unified order → return payment params - `handleWxNotify(xml)`: - Parse and verify signature - Find order by orderNo - Transaction: update Order to PAID, create Membership (calculate expireDate from cardType.durationDays, set remainingTimes from cardType.totalTimes) - `getMyOrders(userId)`: paginated order list - `getOrdersByAdmin()`: ADMIN view 3. `PaymentController`: - `POST /payment/create-order` — authed - `POST /payment/wx-notify` — public (WeChat callback, no auth) - `GET /payment/orders` — authed - `GET /admin/orders` — ADMIN **Tests:** - createOrder: creates PENDING order with correct amount - handleWxNotify: valid callback → order PAID, membership created with correct expiry - handleWxNotify: duplicate callback → idempotent (no duplicate membership) - handleWxNotify: invalid signature → rejected **Verify:** `pnpm test -- --testPathPattern=payment` **Commit:** `feat(server): add payment module with WeChat Pay integration` --- ### Task 2.10: 定时任务 **Files to create:** - `packages/server/src/scheduler/scheduler.module.ts` - `packages/server/src/scheduler/scheduler.service.ts` - `packages/server/src/scheduler/__tests__/scheduler.service.spec.ts` **Steps:** 1. Use `@nestjs/schedule` with `@Cron`: - Daily 2:00 AM: `SlotGeneratorService.generateSlots(14)` — generate slots for next 14 days - Daily 2:30 AM: `SlotGeneratorService.cleanupExpiredSlots()` — close past slots - Daily 3:00 AM: `SlotGeneratorService.checkExpiredMemberships()` — expire memberships - Daily 22:00: Mark today's CONFIRMED bookings as COMPLETED (lesson is done) 2. Register `ScheduleModule.forRoot()` in AppModule. **Tests:** Mock cron, verify each task calls the right service method. **Verify:** `pnpm test -- --testPathPattern=scheduler` **Commit:** `feat(server): add scheduled tasks for slot generation and cleanup` --- ### Task 2.11: 数据库 seed 脚本 **Files to create:** - `packages/server/prisma/seed.ts` **Steps:** 1. Create seed data: - 1 StudioConfig record with placeholder values - 3 CardType records: 「私教10次卡」(TIMES, 10次, 90天, 2999元), 「月卡」(DURATION, 30天, 999元), 「体验课」(TRIAL, 1次, 7天, 99元) - 1 ADMIN user (with a test openid) - WeekTemplate for weekdays 09:00-10:00, 10:00-11:00, 14:00-15:00, 15:00-16:00 2. Use `prisma.$transaction` for atomicity, skip if records already exist. **Verify:** `pnpm prisma db seed` runs successfully. **Commit:** `feat(server): add database seed script` --- ## Phase 3: 前端基础 ### Task 3.1: 初始化 uni-app 项目 **Files to create:** - `packages/app/package.json` - `packages/app/tsconfig.json` - `packages/app/vite.config.ts` - `packages/app/src/main.ts` - `packages/app/src/App.vue` - `packages/app/src/pages.json` - `packages/app/src/manifest.json` - `packages/app/src/uni.scss` - `packages/app/index.html` **Steps:** 1. Initialize with `@dcloudio/uni-app` (Vue 3 + Vite + TypeScript template). 2. Install dependencies: - `@dcloudio/uni-app`, `@dcloudio/uni-mp-weixin`, `@dcloudio/uni-ui` - `pinia`, `@mp-pilates/shared` 3. Configure `pages.json` with tab bar (3 tabs) and all sub-pages: ```json { "pages": [ { "path": "pages/home/index", "style": { "navigationBarTitleText": "首页" } }, { "path": "pages/booking/index", "style": { "navigationBarTitleText": "预约课程" } }, { "path": "pages/profile/index", "style": { "navigationBarTitleText": "我的" } } ], "subPackages": [ { "root": "pages/card", "pages": [ { "path": "detail", "style": { "navigationBarTitleText": "购买会员卡" } } ] }, { "root": "pages/profile", "pages": [ { "path": "membership", "style": { "navigationBarTitleText": "我的会员卡" } }, { "path": "bookings", "style": { "navigationBarTitleText": "我的预约" } }, { "path": "info", "style": { "navigationBarTitleText": "个人信息" } } ] }, { "root": "pages/admin", "pages": [ { "path": "index", "style": { "navigationBarTitleText": "管理中心" } }, { "path": "week-template", "style": { "navigationBarTitleText": "排课设置" } }, { "path": "slot-adjust", "style": { "navigationBarTitleText": "时段调整" } }, { "path": "members", "style": { "navigationBarTitleText": "会员管理" } }, { "path": "orders", "style": { "navigationBarTitleText": "订单管理" } }, { "path": "card-types", "style": { "navigationBarTitleText": "卡种管理" } }, { "path": "studio", "style": { "navigationBarTitleText": "工作室设置" } } ] } ], "tabBar": { "color": "#999", "selectedColor": "#333", "list": [ { "pagePath": "pages/home/index", "text": "首页", "iconPath": "static/tab/home.png", "selectedIconPath": "static/tab/home-active.png" }, { "pagePath": "pages/booking/index", "text": "预约", "iconPath": "static/tab/booking.png", "selectedIconPath": "static/tab/booking-active.png" }, { "pagePath": "pages/profile/index", "text": "我的", "iconPath": "static/tab/profile.png", "selectedIconPath": "static/tab/profile-active.png" } ] } } ``` 4. Configure `manifest.json` with WeChat appid placeholder and permission settings. **Verify:** `cd packages/app && pnpm dev:mp-weixin` compiles without error. **Commit:** `feat(app): initialize uni-app project with page routing` --- ### Task 3.2: 前端基础设施 (HTTP, Auth, Store) **Files to create:** - `packages/app/src/utils/request.ts` - `packages/app/src/utils/auth.ts` - `packages/app/src/stores/user.ts` - `packages/app/src/stores/studio.ts` **Steps:** 1. `utils/request.ts` — 封装 `uni.request`: - Base URL from env config - Auto attach JWT token header - Response interceptor: handle 401 (redirect to login), extract `data` from ApiResponse - Request/response typing with generics ```typescript export function request(options: RequestOptions): Promise { return new Promise((resolve, reject) => { const token = uni.getStorageSync('token') uni.request({ url: `${BASE_URL}${options.url}`, 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') // redirect to login reject(new Error('Unauthorized')) return } const body = res.data as ApiResponse if (body.success) { resolve(body.data as T) } else { reject(new Error(body.message || 'Request failed')) } }, fail: reject, }) }) } ``` 2. `utils/auth.ts` — WeChat login helper: - `wxLogin()`: call `wx.login()`, send code to `/auth/login`, store JWT - `wxGetPhone(e)`: extract encrypted data, send to `/auth/phone` - `checkAuth()`: check token exists, validate 3. `stores/user.ts` (Pinia): - State: `user`, `token`, `stats`, `memberships` - Actions: `login()`, `fetchProfile()`, `fetchStats()`, `fetchMemberships()` - Getters: `isLoggedIn`, `isAdmin`, `hasValidMembership`, `activeMembership` 4. `stores/studio.ts` (Pinia): - State: `studioInfo` - Actions: `fetchStudioInfo()` **Verify:** Build compiles. Stores are importable and typed. **Commit:** `feat(app): add HTTP client, auth utils, and Pinia stores` --- ## Phase 4: 前端页面 ### Task 4.1: 首页 **Files to create:** - `packages/app/src/pages/home/index.vue` - `packages/app/src/components/BrandBanner.vue` - `packages/app/src/components/StudioInfo.vue` - `packages/app/src/components/QuickEntry.vue` - `packages/app/src/components/UpcomingBooking.vue` - `packages/app/src/components/CardShop.vue` **Steps:** 1. `BrandBanner`: display logo, studio name, background image from StudioConfig. 2. `StudioInfo`: photo swiper, address (tap → map), phone (tap → call). 3. `QuickEntry`: conditional display based on user state: - Not logged in → 「微信登录」 - New user (no memberships) → 「预约体验课」 - Has valid card → 「一键约课」 - Card running low (<=2 remaining) → 「续卡提醒」 - No valid card → 「购买会员卡」 4. `UpcomingBooking`: show next 1-2 upcoming bookings with date/time. 5. `CardShop`: horizontal scrollable card type list, tap → card detail page. **Verify:** Page renders in WeChat DevTools. Quick entries change based on mock data. **Commit:** `feat(app): implement home page with smart entry points` --- ### Task 4.2: 课程预约页 **Files to create:** - `packages/app/src/pages/booking/index.vue` - `packages/app/src/components/DateSelector.vue` - `packages/app/src/components/TimePeriodFilter.vue` - `packages/app/src/components/SlotCard.vue` - `packages/app/src/components/BookingConfirmPopup.vue` - `packages/app/src/stores/booking.ts` **Steps:** 1. `DateSelector`: horizontal scroll, 7 days, highlight today, show weekday. Active date fetches slots. 2. `TimePeriodFilter`: tabs for 全部/上午/下午/晚上, filter displayed slots. 3. `SlotCard`: display time range, status (available/full/booked). Tap → booking flow. 4. `BookingConfirmPopup`: uni-popup showing slot info + membership to deduct + confirm/cancel buttons. 5. `stores/booking.ts`: fetch slots by date, create booking, cancel booking. **Booking flow in component:** ``` tap "预约" → check login → check membership → no membership → navigate to card purchase → has membership → show BookingConfirmPopup → confirm → call createBooking API → success toast → refresh slots ``` **Verify:** Page loads slots, date switching works, booking popup shows. **Commit:** `feat(app): implement booking page with date selector and reservation flow` --- ### Task 4.3: 个人中心页 **Files to create:** - `packages/app/src/pages/profile/index.vue` - `packages/app/src/components/UserCard.vue` - `packages/app/src/components/TrainingStats.vue` - `packages/app/src/components/ProfileMenu.vue` **Steps:** 1. `UserCard`: avatar, nickname, phone, total training days/count. 2. `TrainingStats`: this month's count/days/hours in a card layout. 3. `ProfileMenu`: list items — 我的会员卡, 我的预约, 个人信息. If ADMIN → 管理中心 entry at bottom. 4. Login prompt if not authenticated. **Verify:** Page renders, menu items navigate correctly. **Commit:** `feat(app): implement profile page with stats and menu` --- ### Task 4.4: 子页面 — 会员卡购买、我的会员卡、我的预约、个人信息 **Files to create:** - `packages/app/src/pages/card/detail.vue` - `packages/app/src/pages/profile/membership.vue` - `packages/app/src/pages/profile/bookings.vue` - `packages/app/src/pages/profile/info.vue` **Steps:** 1. **卡种详情/购买** (`card/detail.vue`): - Receive `cardTypeId` via route param - Display card info: name, type, price (with original price strikethrough), description, duration - "立即购买" button → call createOrder → `wx.requestPayment` → success → navigate to my memberships 2. **我的会员卡** (`profile/membership.vue`): - List all memberships grouped: ACTIVE first, then EXPIRED/USED_UP - Each card shows: name, remaining times or expire date, status badge 3. **我的预约** (`profile/bookings.vue`): - Two tabs: 即将上课 / 历史记录 - Upcoming: show date, time, cancel button (with time check) - History: show status (COMPLETED, CANCELLED, NO_SHOW) 4. **个人信息** (`profile/info.vue`): - Edit nickname (text input) - Bind/change phone (wx.getPhoneNumber button) - Avatar display (from WeChat) **Verify:** Each page renders and connects to API correctly. **Commit:** `feat(app): implement card purchase, membership, bookings, and profile info pages` --- ### Task 4.5: 管理页面 **Files to create:** - `packages/app/src/pages/admin/index.vue` - `packages/app/src/pages/admin/week-template.vue` - `packages/app/src/pages/admin/slot-adjust.vue` - `packages/app/src/pages/admin/members.vue` - `packages/app/src/pages/admin/orders.vue` - `packages/app/src/pages/admin/card-types.vue` - `packages/app/src/pages/admin/studio.vue` - `packages/app/src/stores/admin.ts` **Steps:** 1. **管理中心** (`admin/index.vue`): grid menu with icons — 排课设置, 时段调整, 会员管理, 订单管理, 卡种管理, 工作室设置. 2. **周模板管理** (`admin/week-template.vue`): - 7-column grid (Mon-Sun) - Each day shows time slots as tags - Add button per day → time picker (start/end) → add template - Toggle switch per slot to enable/disable - Save → PUT /admin/week-template (full replacement) 3. **临时时段调整** (`admin/slot-adjust.vue`): - Calendar date picker - Show generated slots for selected date - Close slot button, Add manual slot form - "重新生成" button → POST /admin/generate-slots 4. **会员管理** (`admin/members.vue`): - Search by nickname/phone - List: avatar, nickname, active card info, total bookings - Tap → member detail (memberships + booking history) 5. **订单管理** (`admin/orders.vue`): - List: order no, user, card type, amount (format from cents), status, time - Status filter tabs (ALL, PENDING, PAID, REFUNDED) 6. **卡种管理** (`admin/card-types.vue`): - List current card types with edit/delete - Add new card type form: name, type, times, duration, price, original price, description - Toggle isActive 7. **工作室设置** (`admin/studio.vue`): - Form: name, address, phone, cancelHoursLimit - Image upload for logo, banner, photos - Map picker for lat/lng (optional, use wx.chooseLocation) 8. `stores/admin.ts`: admin-specific API calls for all admin endpoints. **Verify:** All admin pages render and CRUD operations work. **Commit:** `feat(app): implement admin management pages` --- ## Phase 5: 集成与完善 ### Task 5.1: 端到端联调 **Steps:** 1. Start server with seed data 2. Open mini-program in WeChat DevTools 3. Test complete flows: - Login → home page → quick entries display correctly - Purchase card → payment flow (mock mode) - Book a slot → confirm → check deduction - Cancel booking → verify refund logic - Admin: create week template → generate slots → verify on booking page 4. Fix any integration issues **Verify:** All flows work end-to-end in WeChat DevTools. **Commit:** `fix: resolve integration issues from e2e testing` --- ### Task 5.2: 错误处理与 loading 状态 **Files to create/modify:** - `packages/app/src/components/LoadingState.vue` - `packages/app/src/components/EmptyState.vue` - `packages/app/src/components/ErrorToast.vue` **Steps:** 1. Add loading skeletons to list pages (booking, memberships, admin lists). 2. Add empty state illustrations for: no bookings, no memberships, no slots. 3. Add error handling: network error toast, retry mechanism. 4. Add pull-to-refresh on list pages. 5. Add `uni.showLoading` / `uni.hideLoading` for mutation operations (book, cancel, purchase). **Verify:** Loading states display, errors are caught and shown, empty states render. **Commit:** `feat(app): add loading states, empty states, and error handling` --- ### Task 5.3: 服务端全量测试 **Steps:** 1. Run full test suite: `cd packages/server && pnpm test` 2. Check coverage: `pnpm test -- --coverage` 3. Ensure ≥80% coverage on: - `booking.service.ts` (critical business logic) - `membership.service.ts` (deduction logic) - `payment.service.ts` (order + callback) - `slot-generator.service.ts` (slot generation) - `auth.service.ts` (login flow) 4. Add missing tests if below threshold. **Verify:** `pnpm test -- --coverage` shows ≥80% on critical modules. **Commit:** `test(server): ensure 80%+ coverage on critical business modules` --- ## Execution Notes ### Dependency Order - Phase 1 → Phase 2 → Phase 3 → Phase 4 → Phase 5 - Within Phase 2: Tasks 2.1, 2.2 must come first. Tasks 2.3-2.5 can be parallelized. Tasks 2.6-2.8 depend on 2.3 (auth). Task 2.9 depends on 2.6. Task 2.10 depends on 2.7. - Within Phase 4: Task 4.1-4.3 can be parallelized after 3.2. Task 4.4 depends on 4.1-4.3 stores. Task 4.5 depends on all backend modules. ### Parallelizable Tasks - **Phase 2**: Auth (2.3) + Studio (2.5) + User (2.4) can run in parallel after 2.2 - **Phase 4**: Home (4.1) + Booking (4.2) + Profile (4.3) can run in parallel after 3.2 ### Key Risks 1. **WeChat Pay integration**: Requires real merchant credentials for testing. Use mock mode first, real integration last. 2. **uni-app + pnpm workspace**: `shamefully-hoist=true` is needed. May need to adjust `vite.config.ts` for shared package resolution. 3. **Prisma in monorepo**: Need to ensure `prisma generate` runs before server build. Add as `postinstall` script. ### Total Commits: ~17 ### Estimated Tasks: 16 main tasks across 5 phases