Files
mp-pilates/docs/superpowers/plans/2026-04-02-pilates-booking-miniprogram.md
richarjiang 90b54d1138 feat: scaffold monorepo with shared types and NestJS server
- pnpm workspace with packages/app, packages/server, packages/shared
- @mp-pilates/shared: enums, constants, TypeScript interfaces for all 8 data models
- @mp-pilates/server: NestJS bootstrap with health check, validation pipe, CORS
- Base TypeScript config with strict mode
2026-04-02 11:37:35 +08:00

1121 lines
36 KiB
Markdown

# 普拉提私教约课小程序 — 实施计划
> **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<T> {
readonly success: boolean
readonly data: T | null
readonly message: string | null
}
export interface PaginatedResponse<T> extends ApiResponse<T[]> {
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<T>(options: RequestOptions): Promise<T> {
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<T>
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