Compare commits

..

39 Commits

Author SHA1 Message Date
richarjiang
14d7c03b05 perf: 更新微信开发者工具调用 skill 2026-04-19 22:45:43 +08:00
richarjiang
bd3d519b4f feat(app): 新增个人中心课表视图 2026-04-19 22:23:23 +08:00
richarjiang
9575210b06 feat: 支持分享邀请好友功能 2026-04-19 14:12:25 +08:00
richarjiang
b02f38dcc7 feat(card): add cover image support for card types 2026-04-15 23:50:12 +08:00
richarjiang
4dacd908a6 feat: 优化排课管理 2026-04-15 23:25:09 +08:00
richarjiang
6ab16f508a feat: 支持画廊图片更新 2026-04-15 13:58:51 +08:00
richarjiang
7ce7cef77c feat: UI 页面优化 2026-04-14 10:07:31 +08:00
richarjiang
52cc3a2985 fix(app): 修复分享页返回首页 2026-04-13 19:23:16 +08:00
richarjiang
497837c1d8 feat(app): refine teacher profile page content 2026-04-13 19:19:33 +08:00
richarjiang
d45a5b2c14 fix(server): support rebooking cancelled slots 2026-04-13 17:50:14 +08:00
richarjiang
f78cdcc9d1 feat: 添加教练详情页面及相关数据模型 2026-04-13 17:08:17 +08:00
richarjiang
1f45c3dc3f perf: 个人中心支持展示约课数量 2026-04-12 22:27:36 +08:00
richarjiang
6cee28bf66 feat: 支持管理员消息推送 2026-04-12 22:18:34 +08:00
richarjiang
c60821c5ff perf: 支持约课以及消息推送能力 2026-04-12 21:44:44 +08:00
richarjiang
9639f44698 fix: 修复订单管理功能 2026-04-12 18:16:18 +08:00
richarjiang
0810f71250 fix: 修复订单列表不能查看的问题 2026-04-10 23:07:56 +08:00
richarjiang
54e30da003 fix(app): 优化首页会员卡闪烁和即将上课卡片交互
- CardShop: 采用 stale-while-revalidate 模式,仅首次加载显示骨架屏,
  切换 tab 回来时保留旧数据静默刷新,消除列表闪烁
- UpcomingBooking: 补充 PENDING_CONFIRMATION 状态的中文映射和样式
- UpcomingBooking: 卡片点击跳转到预约详情页
2026-04-10 11:29:09 +08:00
richarjiang
57e3227af0 fix(app): 移除已废弃的 uni.getUserProfile 调用修复首次登录失败
uni.getUserProfile 在微信基础库 2.27.1+ 已被移除,调用时抛出 TypeError
导致整个登录流程中断。新用户昵称由后端默认生成,用户可在资料页修改。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:04:16 +08:00
richarjiang
54104c16d2 perf: 支持教师、场馆介绍 2026-04-09 14:29:13 +08:00
richarjiang
a40b4e47e5 perf: 优化 UI 2026-04-09 11:31:12 +08:00
richarjiang
74551085e3 feat: 支持秒杀活动 2026-04-09 10:24:44 +08:00
richarjiang
23bdd05811 feat: 支持会员卡设置 2026-04-07 16:47:56 +08:00
richarjiang
91abedcb86 fix(app): prevent modal disappearing when switching to edit tab
Root cause: there was a stale loading overlay outside the tab content
(added by a previous subagent) that rendered when editLoading was true,
covering the entire modal body. Also loadDetailMembership incorrectly
managed editLoading causing state inconsistencies.

Fix:
- Remove the orphaned loading overlay outside tab content blocks
- loadDetailMembership no longer touches editLoading (switchTab owns it)
- switchTab uses Promise.all for parallel loading with formReady guard
- openDetail resets editLoading/formReady to clean state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 10:22:02 +08:00
richarjiang
0a20aef678 fix(app): ensure edit form initializes after async data loads
- switchTab('edit') now awaits fetchCardTypes and loadDetailMembership
  before calling initEditForm, fixing blank form fields for new cards

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 10:13:20 +08:00
richarjiang
e6056bcab1 fix(server): add definite assignment assertions to UpdateUserMembershipDto
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 09:53:14 +08:00
richarjiang
f14da2c538 feat(app): add membership edit modal with tab UI
- Replace detail-only modal with tab-based (detail/edit) modal
- Detail tab shows membership card info, stats, and action buttons
- Edit tab provides form to modify card type, remaining times, start/expire dates
- Add loadDetailMembership, switchTab, initEditForm, onSaveMembership, onClearMembership functions
- Add computed isTimeBasedCard for edit form conditional rendering
- Append new styles for modal-header, tab-bar, membership-card, edit-form, etc.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 09:51:10 +08:00
richarjiang
b281990808 feat(app): add membership management actions to admin store
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 09:44:41 +08:00
richarjiang
cea78aa8d0 feat(server): add admin membership management endpoints
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 09:43:45 +08:00
richarjiang
0776bd8630 feat(server): add membership CRUD methods to UserService
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 09:42:42 +08:00
richarjiang
867461f892 feat(server): add UpdateUserMembershipDto for admin member card management
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 09:41:16 +08:00
richarjiang
13b75c3bed docs: 补充会员卡编辑功能实现计划
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 09:39:43 +08:00
richarjiang
f7f18f5178 docs: 添加会员卡编辑功能设计文档
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 09:35:28 +08:00
richarjiang
0ca93ec97e feat: 支持会员管理筛选 2026-04-07 09:22:58 +08:00
richarjiang
58c7588a96 chore: 添加 CLAUDE.md 和 .env 配置文件
- 添加项目文档 CLAUDE.md,包含常用命令和架构说明
- 添加 packages/server/.env 环境变量配置文件

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 21:46:15 +08:00
richarjiang
f94b48203f feat: 新的预约列表样式 2026-04-06 21:22:18 +08:00
richarjiang
168968f073 feat: 将课程预约卡片改造为电影票风格设计
### 改动内容

#### 1. SlotCard 组件完全重新设计
- **新增票据风格装饰**:左右两侧添加圆角凹陷(打孔效果)
- **三栏布局**:
  - 左:时间(开始时间大号、分割线、结束时间小号)
  - 中:课程名称、时长、剩余名额 + 虚线分割符
  - 右:操作按钮或状态徽章
- **日期展示**:卡片上方显示完整日期 (YYYY-MM-DD)
- **状态指示器**:
  - 绿色:有空位
  - 橙色:接近满额(80% 以上)
  - 红色:已满
  - 灰色:已关闭/已过期
- **响应式样式**:根据预约、满额、已过期等状态自动调整背景颜色和透明度

#### 2. 预约页面 (booking/index.vue) 样式调整
- 移除卡片列表的间距(`gap: 20rpx` → `padding: 24rpx 0 0`)
- 更新日期摘要的内边距(`0 8rpx 4rpx` → `0 24rpx 16rpx`)
- 调整加载骨架屏的尺寸和间距
- 优化预加载动画匹配新卡片设计

### 设计特点
 票据风格的视觉设计
🎯 改进的信息层级和易读性
🎨 精细的状态指示和颜色编码
📱 保持响应式和易用性

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:12:53 +08:00
richarjiang
ba4e4bb301 perf: 优化首页效果 2026-04-06 16:50:28 +08:00
richarjiang
f8268cb6f6 perf: 完善新用户引导 2026-04-06 15:50:22 +08:00
richarjiang
66d47ec162 perf: 优化 UI 2026-04-06 11:15:10 +08:00
144 changed files with 15686 additions and 9870 deletions

View File

@@ -0,0 +1,252 @@
---
name: wechat-devtools-http-preview
description: '通过微信开发者工具 HTTP V2 接口完成小程序登录检查、自动预览、上传体验版等操作。适用于用户提到微信开发者工具 HTTP、自动预览、体验版上传、命令行发布体验版、流水线触发开发者工具发布时。'
license: MIT
allowed-tools: Bash
---
# 微信开发者工具 HTTP V2 体验版发布
## 适用场景
当用户出现以下意图时使用本 skill
- 希望通过命令行或脚本调用微信开发者工具。
- 希望上传小程序体验版。
- 希望生成或刷新预览二维码。
- 希望把开发者工具 HTTP 能力接入本地自动化流程。
- 提到 `HTTP V2``/v2/upload``/v2/preview``/v2/autopreview``微信开发者工具端口` 等关键词。
## 核心结论
微信开发者工具在启动后会自动开启本地 HTTP 服务。对于“通过命令行发布体验版”这个目标,最直接的路径是:
1. 确认开发者工具已启动并拿到本地端口。
2. 确认工具已登录。
3. 如有需要先执行 `/v2/open` 打开项目。
4. 如项目依赖 npm必要时先执行 `/v2/buildnpm`
5. 调用 `/v2/upload` 上传体验版代码。
6. 如需同时给测试同学扫码,调用 `/v2/preview``/v2/autopreview`
文档同时明确说明:如果场景是完全不依赖开发者工具的 CI/CD官方更推荐 `miniprogram-ci`。因此本 skill 的定位是“基于本机已安装且已运行的微信开发者工具做自动化”,不是替代纯 CI 方案。
## 文档沉淀
根据微信开发者工具 HTTP 文档,需要记住这些约束:
- 接口路径统一使用 `/v2` 前缀。
- HTTP 服务会在开发者工具启动后自动开启。
- 端口号记录在用户目录下的 `.ide` 文件。
- `project` 参数一般都要求传项目绝对路径,且必须 URL encode。
- 项目目录必须存在合法的 `project.config.json`,并至少包含 `appid``projectname`
- `upload` 用于上传代码,也就是生成体验版。
- `preview` 返回预览二维码。
- `autopreview` 会自动刷新并预览项目,适合频繁本地联调。
- `islogin` 可用于判断当前开发者工具是否已登录。
- `login` 可输出二维码,支持 `image``base64``terminal` 三种格式。
- `info-output` 可把预览或上传附加信息输出到 JSON 文件,适合自动化流程记录产物。
## 端口定位
开发者工具端口号文件:
- macOS: `~/Library/Application Support/微信开发者工具/<MD5>/Default/.ide`
- Windows: `~/AppData/Local/微信开发者工具/User Data/<MD5>/Default/.ide`
文档给出的 MD5 规则:`MD5(${installPath}${nwVersion})`
已知默认值:
- macOS: `installPath = /Applications/wechatwebdevtools.app/Contents/MacOS`
- macOS: `nwVersion = ''`
- Windows: `installPath = 微信开发者工具.exe 所在目录`
- Windows: `nwVersion = installPath/version` 文件中 `latestNw` 的值
## 标准工作流
### 1. 检查工具是否启动
先读取 `.ide` 文件中的端口号;如果没有端口文件,说明工具大概率未启动,先提示用户启动微信开发者工具。
### 2. 检查是否已登录
调用:
```bash
curl "http://127.0.0.1:${PORT}/v2/islogin"
```
如果未登录,调用 `/v2/login`,按需要输出二维码:
```bash
curl "http://127.0.0.1:${PORT}/v2/login?qr-format=terminal"
curl "http://127.0.0.1:${PORT}/v2/login?qr-format=base64&qr-output=%2Ftmp%2Fwechat-login.txt"
curl "http://127.0.0.1:${PORT}/v2/login?result-output=%2Ftmp%2Fwechat-login-result.json"
```
### 3. 打开或刷新项目
```bash
curl "http://127.0.0.1:${PORT}/v2/open?project=${ENCODED_PROJECT}"
```
当用户只要求上传或预览时,这一步不是必选,但执行后通常更稳定。
### 4. 构建 npm
当项目启用了小程序 npm 并且近期依赖有变更时执行:
```bash
curl "http://127.0.0.1:${PORT}/v2/buildnpm?project=${ENCODED_PROJECT}&compile-type=miniprogram"
```
### 5. 上传体验版
体验版发布的核心接口就是:
```bash
curl "http://127.0.0.1:${PORT}/v2/upload?project=${ENCODED_PROJECT}&version=${VERSION}&desc=${DESC}"
```
推荐同时加 `info-output`
```bash
curl "http://127.0.0.1:${PORT}/v2/upload?project=${ENCODED_PROJECT}&version=${VERSION}&desc=${DESC}&info-output=${ENCODED_INFO_OUTPUT}"
```
参数要求:
- `project`:必填,项目绝对路径。
- `version`:必填,版本号。
- `desc`:可选,版本备注。
- `info-output`:可选,输出上传附加信息 JSON。
### 6. 生成预览二维码
如果用户还需要扫码体验,可继续调用:
```bash
curl "http://127.0.0.1:${PORT}/v2/preview?project=${ENCODED_PROJECT}&qr-format=terminal"
curl "http://127.0.0.1:${PORT}/v2/preview?project=${ENCODED_PROJECT}&qr-format=base64&qr-output=${ENCODED_QR_PATH}"
curl "http://127.0.0.1:${PORT}/v2/autopreview?project=${ENCODED_PROJECT}&info-output=${ENCODED_INFO_OUTPUT}"
```
区别:
- `/v2/preview`:单次预览,拿二维码最直接。
- `/v2/autopreview`:更适合联调时自动刷新预览。
## 推荐命令模板
### macOS 读取端口
如果已知是默认安装路径,可用下面的方式快速算出 `.ide` 路径:
```bash
MD5=$(printf '/Applications/wechatwebdevtools.app/Contents/MacOS' | md5)
PORT_FILE="$HOME/Library/Application Support/微信开发者工具/${MD5}/Default/.ide"
PORT=$(cat "$PORT_FILE")
```
### URL encode 项目路径
```bash
PROJECT="/absolute/path/to/miniprogram"
ENCODED_PROJECT=$(python3 - <<'PY'
import os, urllib.parse
print(urllib.parse.quote(os.environ['PROJECT'], safe=''))
PY
)
```
### 上传体验版完整示例
```bash
PROJECT="/absolute/path/to/miniprogram"
VERSION="1.2.3"
DESC="体验版发布:修复预约课表"
INFO_OUTPUT="/tmp/wechat-upload-info.json"
ENCODED_PROJECT=$(python3 - <<'PY'
import os, urllib.parse
print(urllib.parse.quote(os.environ['PROJECT'], safe=''))
PY
)
ENCODED_DESC=$(python3 - <<'PY'
import os, urllib.parse
print(urllib.parse.quote(os.environ['DESC'], safe=''))
PY
)
ENCODED_INFO_OUTPUT=$(python3 - <<'PY'
import os, urllib.parse
print(urllib.parse.quote(os.environ['INFO_OUTPUT'], safe=''))
PY
)
curl "http://127.0.0.1:${PORT}/v2/upload?project=${ENCODED_PROJECT}&version=${VERSION}&desc=${ENCODED_DESC}&info-output=${ENCODED_INFO_OUTPUT}"
```
## 执行规则
当你代表用户执行这个流程时,按下面顺序做:
1. 先确认当前系统是否安装并启动了微信开发者工具。
2. 优先读取 `.ide` 端口文件,而不是盲猜端口。
3. 上传前先验证 `project.config.json` 是否存在且含 `appid``projectname`
4. 涉及路径、备注、输出文件参数时,一律 URL encode。
5. 如果 `islogin` 未登录,先引导用户扫码登录,不要直接继续上传。
6. 如果用户目标是“发布体验版”,优先使用 `/v2/upload`;不要误用 `/preview` 代替。
7. 如果用户目标是“出二维码给别人扫”,优先使用 `/v2/preview`
8. 如果用户目标是“边改边自动刷新”,优先使用 `/v2/autopreview`
## 故障排查
### 端口文件不存在
说明通常是开发者工具没启动,或者安装路径/MD5 推导错了。
排查顺序:
1. 让用户先手动打开微信开发者工具。
2. 检查是否使用了正确安装路径。
3. 重新计算 MD5。
### 调用 HTTP 接口失败
优先检查:
1. 是否能访问 `http://127.0.0.1:${PORT}/v2/islogin`
2. `PORT` 是否来自正确的 `.ide` 文件。
3. 开发者工具版本是否支持 `HTTP V2`
### 上传失败
优先检查:
1. `project` 是否为绝对路径。
2. 是否已 URL encode。
3. `project.config.json` 是否存在。
4. `project.config.json` 里是否包含 `appid``projectname`
5. 是否已登录工具。
6. npm 依赖是否需要先执行 `/v2/buildnpm`
## 输出要求
当用户让你“帮我发布体验版”时,最终回复必须明确交代:
- 是否成功调用了 `/v2/upload`
- 使用的版本号与备注
- 是否生成了 `info-output` 文件
- 如果还做了预览,二维码输出到哪里或以什么格式返回
如果没有成功执行,必须明确停在哪一步,不要模糊地说“可能发布了”。
## 引用来源
本 skill 基于微信开放文档:
- 页面:微信开发者工具 HTTP V2
- 关键接口:`/v2/login``/v2/islogin``/v2/open``/v2/buildnpm``/v2/upload``/v2/preview``/v2/autopreview`

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

37
AGENTS.md Normal file
View File

@@ -0,0 +1,37 @@
# Repository Guidelines
## Project Structure & Module Organization
This repository is a `pnpm` workspace with three packages under `packages/`:
- `packages/app`: Vue 3 + `uni-app` WeChat mini-program. Pages live in `src/pages`, shared UI in `src/components`, state in `src/stores`, and utilities in `src/utils`.
- `packages/server`: NestJS API with Prisma. Feature modules live in `src/<domain>`, unit tests are colocated in `__tests__`, and the database schema and seed script are in `prisma/`.
- `packages/shared`: shared TypeScript enums, constants, and types used by both app and server.
Reference docs and implementation notes are under `docs/`.
## Build, Test, and Development Commands
- `pnpm dev:server`: start the NestJS API with watch mode.
- `pnpm dev:app`: build and serve the `uni-app` target for WeChat mini-program development.
- `pnpm build:shared`: compile shared types before consuming package changes.
- `pnpm build:server`: build the backend into `packages/server/dist`.
- `pnpm build:app`: produce the WeChat mini-program build.
- `pnpm test`: run workspace tests; today this mainly executes server Jest tests.
- `pnpm lint`: run workspace linting; currently defined for the server.
For Prisma tasks, work in `packages/server`: `pnpm prisma:generate`, `pnpm prisma:migrate`, `pnpm prisma:seed`.
## Coding Style & Naming Conventions
Use TypeScript throughout. Follow the existing style: 2-space indentation in JSON/Markdown, `camelCase` for variables/functions, `PascalCase` for Vue components and NestJS classes, and `kebab-case` for page/component filenames such as `flash-sales.vue`. Keep modules feature-oriented and prefer colocating DTOs and tests with their domain module.
Linting is configured in the server via ESLint. The app relies on `vue-tsc` for type checks: run `pnpm --filter @mp-pilates/app type-check` before shipping UI changes.
## Testing Guidelines
Server tests use Jest with `*.spec.ts` naming. Place tests in `packages/server/src/**/__tests__/` and focus on service-level behavior and edge cases around booking, membership, payment, and scheduling logic. Run `pnpm test` for the full suite or `pnpm --filter @mp-pilates/server test:cov` when touching business-critical paths.
## Commit & Pull Request Guidelines
Recent history uses Conventional Commit prefixes such as `feat:`, `fix:`, `fix(app):`, and `perf:`. Keep subjects short and specific, preferably describing the user-visible effect.
Pull requests should include a concise summary, linked issue or task reference, test notes, and screenshots or recordings for mini-program UI changes. Call out Prisma schema changes, new environment variables, or deployment steps explicitly.
## Security & Configuration Tips
Do not commit real secrets. Review `packages/server/certs/` and environment-specific payment or WeChat credentials carefully before pushing. When changing shared types or enums, update both consumers and rebuild `@mp-pilates/shared` to avoid runtime drift.

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)

198
CLAUDE.md
View File

@@ -1,150 +1,88 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
本文档为 Claude Code (claude.ai/code) 在本项目中工作时提供指导。
## 项目概述
普拉提工作室预约与会员管理的微信小程序。TypeScript monorepo包含三个包:
这是一个普拉提预约微信小程序项目,后端采用 NestJS 框架。项目使用 pnpm monorepo 结构,包含 3 个包:
- **packages/server** — NestJS 后端REST API、Prisma ORM、PostgreSQL
- **packages/app** — Vue 3 + Pinia 前端,基于 Uni-app目标平台 mp-weixin
- **packages/shared** — 前后端共用的 TypeScript 类型、枚举常量
- **packages/app** - Vue 3 + uni-app微信小程序前端
- **packages/server** - NestJS后端 API 服务
- **packages/shared** - TypeScript 类型定义、枚举常量(前后端共用)
## 常用命令
### 开发
```bash
pnpm dev:server # NestJS watch 模式 (localhost:3000)
pnpm dev:app # 微信小程序开发服务器
pnpm build:shared # 必须先构建 shared再构建 server/app
```
# 开发
pnpm dev:server # 启动 NestJS 后端(热重载)
pnpm dev:app # 构建 uni-app 为微信小程序
### 测试(仅 server
```bash
# 构建
pnpm build:shared # 编译共享类型
pnpm build:server # 构建 NestJS 后端
pnpm build:app # 构建微信小程序
# 测试与代码检查
pnpm test # 运行所有测试(仅 server
pnpm lint # 运行 ESLint仅 server
# 数据库相关(位于 packages/server 目录)
cd packages/server
pnpm test # 运行全部测试
pnpm test -- auth.service.spec # 运行单个测试文件
pnpm test:watch # watch 模式
pnpm test:cov # 覆盖率报告
pnpm prisma:generate # 生成 Prisma 客户端
pnpm prisma:migrate # 执行数据库迁移
pnpm prisma:seed # 填充测试数据
pnpm test:watch # 监听模式运行测试
# 部署
pnpm deploy:server # 部署后端到生产环境
```
Jest 配置内联在 `packages/server/package.json`。测试文件位于 `__tests__/` 子目录(如 `src/auth/__tests__/auth.service.spec.ts`),匹配模式:`*.spec.ts`
## 架构说明
### 前端 (packages/app)
- 基于 Vue 3 + uni-app 框架,主攻微信小程序平台
- 页面目录:`src/pages/`(包含 home、booking、card、profile、admin 等模块)
- 组件目录:`src/components/`
- 状态管理Pinia
- 样式SCSS
### 后端 (packages/server)
- 框架NestJS + Prisma ORM
- 核心模块auth认证、user用户、booking预约、membership会员卡、payment支付、studio场馆、time-slot时段、scheduler定时任务、admin管理
- 认证JWT + 微信登录
- 定时任务:@nestjs/schedule
- 数据库SQLite开发/ MySQL生产
### 共享包 (packages/shared)
- TypeScript 接口和类型定义
- 枚举值定义
- 前后端共用的 DTO 类型
### API 结构
- 所有接口统一前缀:`/api`
- RESTful 风格接口
- 全局拦截器:日志记录、响应包装
- 全局过滤器:异常处理
### 数据库
```bash
cd packages/server
pnpm prisma:generate # schema 变更后重新生成 Prisma Client
pnpm prisma:migrate # 运行迁移(交互式)
pnpm prisma:seed # 填充种子数据
```
- Prisma schema 位于 `packages/server/prisma/schema.prisma`
- 核心数据模型User、Studio、TimeSlot、Booking、Membership、CardType、Order
- 注意查询会员列表时booking 统计通过 `groupBy` 批量获取,避免 N+1 查询
### 代码检查
```bash
pnpm lint # 所有包的 ESLint 检查
```
### 卡类型枚举
- `CardTypeCategory` (TIMES/DURATION/TRIAL) 定义在 `packages/shared/src/enums.ts`
- 会员管理筛选使用特殊值 `NONE` 表示无卡/无有效会员(不在枚举中)
- 前端选项硬编码在 `src/pages/admin/members.vue``cardTypeOptions`,需与枚举保持同步
## 架构
### 管理后台 API 模式
- `/admin/members` 支持 `page`, `limit`, `search`, `cardType` 参数
- `cardType=NONE` → 无有效会员的用户;其他值对应 `CardTypeCategory`
- 预约统计total/completed/cancelled通过 `groupBy` 批量查询
### 数据流
```
微信小程序 → Uni-app (Vue 3) → REST API (NestJS) → Prisma → PostgreSQL
↕ ↕
Pinia stores @nestjs/schedule (定时任务)
```
### 筛选组件模式
- picker 筛选使用 300ms debounce 再触发加载,避免频繁请求
- 列表分页使用 `onReachBottom` + `hasMore` 标志位实现无限滚动
### 后端模块结构
每个功能是一个 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>` 块内直接写十六进制颜色值(背景色、文字色、边框、阴影均需走变量)
### Admin Store (`src/stores/admin.ts`)
- 聚合所有管理端 API 调用weekTemplates、cardTypes、studioConfig、members、bookings、orders、stats 等
- 遵循不可变更新原则:`data` 赋值使用展开运算符 `[...newData]`

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)

338
docs/STUDIO_COS_SETUP.md Normal file
View File

@@ -0,0 +1,338 @@
# 工作室画廊 COS 接入配置说明
本文档对应当前仓库当前实现。
现在已经不再使用 STS `AssumeRole`
当前方案改为:
- 服务端使用长期密钥直接签发 COS POST Policy
- 管理中心小程序拿到表单签名后直传 COS
- 工作室配置中的 `logo``bannerUrl``photos` 保存最终可访问 URL
当前实现代码入口:
- `packages/server/src/studio/studio-upload.service.ts`
- `packages/server/src/studio/studio.controller.ts`
- `packages/app/src/utils/studio-upload.ts`
- `packages/app/src/pages/admin/studio.vue`
## 一、整体链路
1. 管理中心点击上传图片。
2. 小程序请求服务端 `POST /api/admin/studio/upload-credentials`
3. 服务端用 `COS_SECRET_ID``COS_SECRET_KEY` 直接生成一组 POST Policy 表单字段。
4. 服务端把 `uploadUrl``key``formData``fileUrl``expiresAt` 返回给小程序。
5. 小程序使用 `uni.uploadFile` 直接上传到 COS。
6. 上传成功后,把 URL 保存到工作室配置,再调用 `PUT /api/admin/studio/info` 落库。
这个方案没有临时密钥,也没有角色扮演。
安全边界来自两层:
- 服务端只为单个对象 key 签发一次表单策略
- 表单策略有明确过期时间,过期后自动失效
## 二、这个方案的本质
你现在选的是“服务端代签名”的直传方案。
它和 STS 的差别是:
- STS给前端一段时间内可用的短期密钥
- 当前方案:不给前端密钥,只给前端一个短时有效的上传表单签名
所以结论很直接:
- 仍然有有效期
- 但有效期作用在 POST Policy 上,不是作用在临时密钥上
当前代码里默认有效期是 `1800` 秒。
环境变量:
- `COS_UPLOAD_DURATION_SECONDS`
当前实现限制范围:
- 最短 `300`
- 最长 `7200`
## 三、你现在真正需要准备的东西
先确认下面几个信息:
- COS Bucket 名称,例如 `plates-1251306435`
- COS 所在地域,例如 `ap-guangzhou`
- 服务端使用的 COS 长期密钥 `SecretId` / `SecretKey`
- 图片上传前缀,例如 `mp/studio`
- 图片访问域名
建议约定:
- Bucket`plates-1251306435`
- Region`ap-guangzhou`
- Prefix`mp/studio`
## 四、COS 控制台配置
### 1. 创建或确认 Bucket
控制台路径:`对象存储 COS`
建议:
- 地域选 `广州` 或你当前实际地域
- 存储类型标准存储即可
- Bucket 名称和环境变量保持完全一致
### 2. 图片访问方式
当前实现保存的是直接图片 URL。
所以图片必须能被小程序和前台直接访问。
你有两种方式:
1. 直接使用 COS 源站并允许读
2. 配 CDN / 自定义域名并让这个域名可直接访问图片
如果你什么都不配,上传成功后图片可能打不开。
最直接做法:
- 让这个图片 Bucket 对外可读
更稳妥做法:
- 单独图片 Bucket
- 用 CDN 域名做 `COS_PUBLIC_BASE_URL`
### 3. 微信小程序合法域名
微信公众平台需要补白名单:
- `request 合法域名`:你的后端 API 域名
- `uploadFile 合法域名``https://<bucket>.cos.<region>.myqcloud.com`
- `downloadFile 合法域名`:图片访问域名
如果图片访问也走 COS 源站,那么 `downloadFile 合法域名` 同样加:
- `https://<bucket>.cos.<region>.myqcloud.com`
例如:
- `https://focus.richarjiang.com`
- `https://plates-1251306435.cos.ap-guangzhou.myqcloud.com`
## 五、服务端账号需要什么权限
现在已经不需要:
- STS
- CAM 角色
- `AssumeRole`
- 角色信任策略
- `COS_UPLOAD_ROLE_ARN`
现在服务端只需要一对可以给目标 Bucket 生成上传签名的长期密钥。
最简单的做法是:
- 用你的主账号密钥
但生产上更合理的是:
- 建一个专用 CAM 用户,只给这个 Bucket 上传相关权限
### 推荐 CAM 用户权限策略
如果你要建专用 CAM 用户,给它绑定下面这类策略即可。
把下面真实值替换成你的实际资源:
- 地域:`ap-guangzhou`
- AppId`1251306435`
- Bucket`plates-1251306435`
- Prefix`mp/studio`
```json
{
"version": "2.0",
"statement": [
{
"effect": "allow",
"action": [
"name/cos:PutObject",
"name/cos:PostObject"
],
"resource": [
"qcs::cos:ap-guangzhou:uid/1251306435:plates-1251306435/mp/studio/*"
]
}
]
}
```
如果你后续还要服务端删除对象,再补:
- `name/cos:DeleteObject`
当前仓库实现不需要删除对象,所以先不要额外放大权限。
## 六、服务端环境变量
把下面变量配置到 `packages/server/.env` 或线上环境:
```env
COS_SECRET_ID=your-cos-secret-id
COS_SECRET_KEY=your-cos-secret-key
COS_BUCKET=plates-1251306435
COS_REGION=ap-guangzhou
COS_PUBLIC_BASE_URL=https://plates-1251306435.cos.ap-guangzhou.myqcloud.com
COS_UPLOAD_PREFIX=mp/studio
COS_UPLOAD_DURATION_SECONDS=1800
```
各字段含义:
- `COS_SECRET_ID`:用于签发 POST Policy 的长期密钥 ID
- `COS_SECRET_KEY`:用于签发 POST Policy 的长期密钥 Key
- `COS_BUCKET`:上传目标 Bucket
- `COS_REGION`Bucket 地域
- `COS_PUBLIC_BASE_URL`:最终展示图片的访问域名
- `COS_UPLOAD_PREFIX`:统一对象前缀
- `COS_UPLOAD_DURATION_SECONDS`Policy 有效期秒数
现在可以删除或忽略这些旧配置:
- `COS_UPLOAD_ROLE_ARN`
- `COS_APP_ID`
- `COS_UPLOAD_ROLE_SESSION_NAME`
它们对当前实现已经没用。
## 七、控制台操作清单
按这个顺序做:
1. 确认 COS Bucket 已存在。
2. 确认图片访问域名对外可读。
3. 在微信公众平台加好 `request` / `uploadFile` / `downloadFile` 合法域名。
4. 准备一对 COS 长期密钥。
5.`COS_SECRET_ID``COS_SECRET_KEY``COS_BUCKET``COS_REGION``COS_PUBLIC_BASE_URL``COS_UPLOAD_PREFIX` 配到服务端。
6. 重启服务端。
7. 在管理中心上传一张图片测试。
## 八、接口返回内容说明
请求:
```http
POST /api/admin/studio/upload-credentials
Content-Type: application/json
Authorization: Bearer <admin-token>
{
"fileName": "demo.jpg",
"contentType": "image/jpeg",
"assetType": "gallery"
}
```
正常返回会包含:
- `uploadUrl`
- `fileUrl`
- `key`
- `assetType`
- `expiresAt`
- `formData`
`formData` 里会有这些字段:
- `key`
- `policy`
- `success_action_status`
- `Content-Type`
- `q-sign-algorithm`
- `q-ak`
- `q-key-time`
- `q-sign-time`
- `q-signature`
这就是小程序直传需要的全部内容。
## 九、怎么验证是否配置正确
### 1. 接口层验证
调用 `POST /api/admin/studio/upload-credentials`
如果成功,说明:
- 服务端长期密钥有效
- 服务端已经能正确签发 policy
### 2. 上传层验证
在管理中心上传一张图,检查:
1. COS Bucket 下是否出现对象
2. 返回的 `fileUrl` 浏览器是否能直接访问
3. 保存工作室设置后首页是否显示该图
### 3. 失败时怎么定位
如果 `upload-credentials` 接口失败,优先检查:
- `COS_SECRET_ID` / `COS_SECRET_KEY` 是否正确
- `COS_BUCKET` / `COS_REGION` 是否正确
- 服务端是否已经加载最新环境变量
如果接口成功但上传失败,优先检查:
- 小程序 `uploadFile 合法域名` 是否正确
- Bucket 权限策略是否允许当前长期密钥上传到该前缀
- `Content-Type` 是否被策略条件限制住
如果上传成功但图片打不开,优先检查:
- Bucket 或图片域名是否可公网访问
- `COS_PUBLIC_BASE_URL` 是否正确
- 小程序 `downloadFile 合法域名` 是否正确
## 十、当前实现的边界
当前仓库实现边界如下:
- 只支持 `jpg``jpeg``png``webp``heic``heif`
- 单次上传大小上限 `10MB`
- 只支持普通表单直传,不支持分片上传
- 删除工作室图片时,只会从数据库配置里移除 URL不会删除 COS 历史对象
最后一条是故意保守设计。
原因很简单:
- 先保证配置删除安全
- 避免误删真实文件
如果以后要做“删配置时同步删对象”,那时再单独加 `DeleteObject` 权限。
## 十一、初始化工作室画廊
如果你要把现在手工写死的图片 URL 一次性写入数据库,执行:
```bash
pnpm --filter @mp-pilates/server studio:seed-gallery
```
脚本文件:
- `packages/server/prisma/update-studio-gallery.ts`
## 十二、建议的生产做法
如果你后面要长期维护,建议:
1. 图片单独放一个 Bucket。
2. 长期密钥不要直接用主账号,换成专用 CAM 用户。
3. 对专用 CAM 用户只给 `mp/studio/*` 前缀上传权限。
4. 用 CDN 域名作为 `COS_PUBLIC_BASE_URL`
这样后面扩展、迁移、审计都会更稳。

View File

@@ -0,0 +1,946 @@
# 会员卡编辑功能实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在管理后台会员列表中,点击会员弹出详情/编辑弹窗,支持编辑卡种、有效期、次数,以及清空会员卡
**Architecture:** 后端在 `UserController` 新增三个 admin 接口GET/PUT/DELETE `/admin/members/:userId/membership`),前端将现有详情弹窗改造为 Tab 模式store 新增三个 action
**Tech Stack:** NestJS (后端) + Vue 3 + uni-app + Pinia (前端)
---
## 文件结构
```
Backend:
- packages/server/src/user/user.controller.ts (新增3个接口)
- packages/server/src/user/user.service.ts (新增3个 service 方法)
- packages/server/src/user/dto/update-user-membership.dto.ts (新建)
Frontend:
- packages/app/src/stores/admin.ts (新增3个 action)
- packages/app/src/pages/admin/members.vue (改造详情弹窗为 Tab 模式)
```
---
## Phase 1: 后端接口
### Task 1: 创建 UpdateUserMembershipDto
**Files:**
- Create: `packages/server/src/user/dto/update-user-membership.dto.ts`
```typescript
import { IsDateString, IsInt, IsOptional, IsUUID, Min } from 'class-validator'
export class UpdateUserMembershipDto {
@IsUUID()
cardTypeId: string
@IsOptional()
@IsInt()
@Min(0)
remainingTimes?: number | null
@IsDateString()
startDate: string
@IsDateString()
expireDate: string
}
```
- [ ] **Step 1: 创建目录并写入文件**
```bash
mkdir -p /Users/richard/Documents/code/pilates/mp-pilates/packages/server/src/user/dto
```
写入 `packages/server/src/user/dto/update-user-membership.dto.ts`
---
### Task 2: UserService 新增会员卡管理方法
**Files:**
- Modify: `packages/server/src/user/user.service.ts` — 在文件末尾添加 3 个新方法
需要添加的方法:
1. `getUserMembership(userId: string)` — 返回 `Membership & { cardType: CardType } | null`
2. `updateUserMembership(userId: string, dto: UpdateUserMembershipDto)` — 创建或更新 membershipstatus 自动计算
3. `deleteUserMembership(userId: string)` — 软删除status → EXPIRED
关键业务逻辑:
- `updateUserMembership` 中 status 计算规则:
- `expireDate < now``EXPIRED`
- `remainingTimes === 0``USED_UP`
- 否则 → `ACTIVE`
- 如果用户已有 membership → 更新;无 → 创建新的
- 需要同时 import `CardTypeCategory``MembershipStatus`
```typescript
// 在 user.service.ts 顶部已有 import追加方法
import { CardTypeCategory, MembershipStatus } from '@mp-pilates/shared'
import { UpdateUserMembershipDto } from './dto/update-user-membership.dto'
import { Membership, CardType, Prisma } from '@prisma/client'
import { PrismaService } from '../prisma/prisma.service'
// 在 class 末尾添加:
async getUserMembership(userId: string): Promise<(Membership & { cardType: CardType }) | null> {
// userId 无唯一约束,取第一条
const membership = await this.prisma.membership.findFirst({
where: { userId },
include: { cardType: true },
})
return membership ? { ...membership, cardType: { ...membership.cardType } } : null
}
async updateUserMembership(
userId: string,
dto: UpdateUserMembershipDto,
): Promise<Membership & { cardType: CardType }> {
// 计算 status
const now = new Date()
const expireDate = new Date(dto.expireDate)
const remainingTimes = dto.remainingTimes ?? null
const isTimeBased = remainingTimes !== null
let status: MembershipStatus = MembershipStatus.ACTIVE
if (expireDate < now) {
status = MembershipStatus.EXPIRED
} else if (isTimeBased && remainingTimes <= 0) {
status = MembershipStatus.USED_UP
}
// 由于 userId 无唯一约束,先查是否存在,取第一条
const existing = await this.prisma.membership.findFirst({ where: { userId } })
let membership: Membership & { cardType: CardType }
if (existing) {
// 已有 membership → 更新
const updated = await this.prisma.membership.update({
where: { id: existing.id },
data: {
cardTypeId: dto.cardTypeId,
remainingTimes: dto.remainingTimes ?? null,
startDate: new Date(dto.startDate),
expireDate: new Date(dto.expireDate),
status,
},
include: { cardType: true },
})
membership = { ...updated, cardType: { ...updated.cardType } }
} else {
// 无 membership → 创建新的
const created = await this.prisma.membership.create({
data: {
userId,
cardTypeId: dto.cardTypeId,
remainingTimes: dto.remainingTimes ?? null,
startDate: new Date(dto.startDate),
expireDate: new Date(dto.expireDate),
status,
},
include: { cardType: true },
})
membership = { ...created, cardType: { ...created.cardType } }
}
return membership
}
async deleteUserMembership(userId: string): Promise<void> {
await this.prisma.membership.updateMany({
where: { userId },
data: { status: MembershipStatus.EXPIRED },
})
}
```
注意:`Membership` 表的 `userId` 暂无唯一约束,实现阶段如有需要可加。`findFirst` + `update` 的方案在只有一张卡时工作正常。
---
### Task 3: UserController 新增 3 个 admin 接口
**Files:**
- Modify: `packages/server/src/user/user.controller.ts`
在文件顶部添加 `@Param` import然后在 `Admin: Member Management` 区块后添加新接口:
```typescript
import {
Controller,
Get,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
} from '@nestjs/common'
// ... existing imports ...
import { UpdateUserMembershipDto } from './dto/update-user-membership.dto'
// 在 getMembers 之后添加:
@Get('admin/members/:userId/membership')
@UseGuards(RolesGuard)
@Roles(UserRole.ADMIN)
getUserMembership(@Param('userId') userId: string) {
return this.userService.getUserMembership(userId)
}
@Put('admin/members/:userId/membership')
@UseGuards(RolesGuard)
@Roles(UserRole.ADMIN)
updateUserMembership(
@Param('userId') userId: string,
@Body() dto: UpdateUserMembershipDto,
) {
return this.userService.updateUserMembership(userId, dto)
}
@Delete('admin/members/:userId/membership')
@UseGuards(RolesGuard)
@Roles(UserRole.ADMIN)
deleteUserMembership(@Param('userId') userId: string) {
return this.userService.deleteUserMembership(userId)
}
```
---
## Phase 2: 前端 Store
### Task 4: Admin Store 新增 3 个 action
**Files:**
- Modify: `packages/app/src/stores/admin.ts`
`MemberSummary` 类型后添加 `UserMembership` 类型(放在 fetchMembers 附近):
```typescript
export interface UserMembership {
userId: string
membership: {
id: string
cardTypeId: string
remainingTimes: number | null
startDate: string
expireDate: string
status: string
cardType: {
id: string
name: string
type: string
totalTimes: number | null
durationDays: number
}
} | null
}
```
`fetchMembers` action 后添加:
```typescript
async function getUserMembership(userId: string): Promise<UserMembership> {
return get<UserMembership>(`/admin/members/${userId}/membership`)
}
async function updateUserMembership(
userId: string,
dto: {
cardTypeId: string
remainingTimes?: number | null
startDate: string
expireDate: string
},
): Promise<any> {
return put<any>(`/admin/members/${userId}/membership`, dto)
}
async function deleteUserMembership(userId: string): Promise<void> {
return del<void>(`/admin/members/${userId}/membership`)
}
```
`return` 对象中导出这三个函数。
---
## Phase 3: 前端 UI详情弹窗改造
### Task 5: members.vue 弹窗改造 — 结构变更
**Files:**
- Modify: `packages/app/src/pages/admin/members.vue`
将现有的详情弹窗(`detail-modal`)从单一视图改造为 Tab 模式。
#### 5.1 模板改动
**原有弹窗代码:**
```vue
<!-- Detail modal -->
<view v-if="showDetail && detailMember" class="modal-mask" @tap.self="showDetail = false">
<view class="modal">
<!-- 详情内容 -->
</view>
</view>
```
**替换为:**
```vue
<!-- Detail / Edit modal -->
<view v-if="showDetail && detailMember" class="modal-mask" @tap.self="closeModal">
<view class="modal">
<!-- Header with close -->
<view class="modal-header">
<text class="modal-title">会员详情</text>
<text class="modal-close-btn" @tap="closeModal">×</text>
</view>
<!-- Tab bar -->
<view class="tab-bar">
<view
class="tab-item"
:class="{ 'tab-item--active': activeTab === 'detail' }"
@tap="switchTab('detail')"
>
<text class="tab-text">详情</text>
</view>
<view
class="tab-item"
:class="{ 'tab-item--active': activeTab === 'edit' }"
@tap="switchTab('edit')"
>
<text class="tab-text">编辑</text>
</view>
</view>
<!-- Tab: 详情 -->
<view v-if="activeTab === 'detail'" class="tab-content">
<!-- 头像区 -->
<view class="detail-header">
<view class="detail-avatar">
<image v-if="detailMember.avatarUrl" class="avatar-img" :src="detailMember.avatarUrl" mode="aspectFill" />
<view v-else class="avatar-placeholder avatar-placeholder--lg">
<text class="avatar-text avatar-text--lg">{{ (detailMember.nickname || '?').slice(0, 1) }}</text>
</view>
</view>
<text class="detail-name">{{ detailMember.nickname || '未知用户' }}</text>
<text class="detail-openid" @tap="copyOpenid(detailMember.openid)">{{ detailMember.openid }}</text>
<text class="detail-phone">{{ detailMember.phone || '未绑定手机' }}</text>
</view>
<!-- 会员卡信息有卡时 -->
<view v-if="detailMembership" class="membership-card">
<view class="membership-card-header">
<text class="membership-card-name">{{ detailMembership.cardType.name }}</text>
<text class="membership-card-badge" :class="'badge--' + detailMembership.status">
{{ statusLabel(detailMembership.status) }}
</text>
</view>
<view class="membership-card-info">
<text class="membership-info-item">有效期{{ formatDate(detailMembership.startDate) }} - {{ formatDate(detailMembership.expireDate) }}</text>
<text v-if="detailMembership.remainingTimes != null" class="membership-info-item">
剩余{{ detailMembership.remainingTimes }}
</text>
</view>
</view>
<!-- 无卡时 -->
<view v-else class="no-membership">
<text class="no-membership-text">暂无会员卡</text>
<view class="no-membership-btn" @tap="goEdit">
<text class="no-membership-btn-text">去开卡</text>
</view>
</view>
<!-- 预约统计 -->
<view class="detail-stats">
<view class="detail-stat">
<text class="detail-stat-value">{{ detailMember.totalBookings }}</text>
<text class="detail-stat-label">总预约</text>
</view>
<view class="detail-stat">
<text class="detail-stat-value">{{ detailMember.completedBookings }}</text>
<text class="detail-stat-label">已完成</text>
</view>
<view class="detail-stat">
<text class="detail-stat-value">{{ detailMember.cancelledBookings }}</text>
<text class="detail-stat-label">已取消</text>
</view>
</view>
<!-- 清空卡按钮有卡时 -->
<view v-if="detailMembership" class="danger-zone">
<view class="danger-btn" @tap="onClearMembership">
<text class="danger-btn-text">解除会员卡</text>
</view>
</view>
</view>
<!-- Tab: 编辑 -->
<view v-if="activeTab === 'edit'" class="tab-content">
<!-- 加载中 -->
<view v-if="editLoading" class="edit-loading">
<text class="edit-loading-text">加载中...</text>
</view>
<!-- 编辑表单 -->
<view v-else class="edit-form">
<!-- 卡种选择 -->
<view class="form-item">
<text class="form-label">卡种</text>
<picker
class="form-picker"
mode="selector"
:value="editForm.cardTypeIndex"
:range="editCardTypes"
range-key="name"
@change="onCardTypeChange"
>
<view class="form-picker-inner">
<text class="form-picker-text">{{ editCardTypes[editForm.cardTypeIndex]?.name || '请选择' }}</text>
<text class="form-picker-arrow"></text>
</view>
</picker>
</view>
<!-- 剩余次数TIMES/TRIAL -->
<view v-if="isTimeBasedCard" class="form-item">
<text class="form-label">剩余次数</text>
<input
class="form-input"
type="number"
v-model="editForm.remainingTimes"
placeholder="请输入剩余次数"
/>
</view>
<!-- 生效日期 -->
<view class="form-item">
<text class="form-label">生效日期</text>
<picker
class="form-picker"
mode="date"
:value="editForm.startDate"
@change="(e) => onStartDateChange(e)"
>
<view class="form-picker-inner">
<text class="form-picker-text">{{ editForm.startDate }}</text>
</view>
</picker>
</view>
<!-- 有效期至 -->
<view class="form-item">
<text class="form-label">有效期至</text>
<picker
class="form-picker"
mode="date"
:value="editForm.expireDate"
@change="(e) => onExpireDateChange(e)"
>
<view class="form-picker-inner">
<text class="form-picker-text">{{ editForm.expireDate }}</text>
</view>
</picker>
</view>
<!-- 保存按钮 -->
<view class="edit-submit">
<view
class="submit-btn"
:class="{ 'submit-btn--disabled': editSubmitting }"
@tap="onSaveMembership"
>
<text class="submit-btn-text">{{ editSubmitting ? '保存中...' : '保存' }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
```
#### 5.2 Script 改动
新增以下状态:
> 注意:在 `import { ref, onMounted, onUnmounted }` 后添加 `computed`
> `import { ref, computed, onMounted, onUnmounted } from 'vue'`
```typescript
const activeTab = ref<'detail' | 'edit'>('detail')
const detailMembership = ref<any>(null) // 当前会员卡数据
const editLoading = ref(false)
const editSubmitting = ref(false)
const editCardTypes = ref<any[]>([]) // 卡种列表(去获取)
const editForm = ref({
cardTypeIndex: 0,
cardTypeId: '',
remainingTimes: null as number | null,
startDate: '',
expireDate: '',
manuallyEditedExpire: false, // 用户手动修改过 expireDate 时为 true
})
// 是否为次数卡TIMES / TRIAL依赖 cardTypeIndex
const isTimeBasedCard = computed(() => {
const card = editCardTypes.value[editForm.value.cardTypeIndex]
return card && (card.type === 'TIMES' || card.type === 'TRIAL')
})
// 工具函数
function formatDate(dateStr: string): string {
if (!dateStr) return '-'
return dateStr.slice(0, 10)
}
function statusLabel(status: string): string {
const map: Record<string, string> = {
ACTIVE: '有效',
EXPIRED: '已过期',
USED_UP: '已用完',
}
return map[status] || status
}
// 懒加载会员卡数据
async function loadDetailMembership(userId: string) {
detailMembership.value = null
try {
const result = await adminStore.getUserMembership(userId)
detailMembership.value = result.membership
} catch {}
}
function switchTab(tab: 'detail' | 'edit') {
activeTab.value = tab
if (tab === 'edit') {
// 切换到编辑 Tab 时加载会员卡数据和卡种列表
if (!editCardTypes.value.length) {
adminStore.fetchCardTypes().then((types) => {
editCardTypes.value = types
})
}
// 如果还没加载 membership先加载
if (!detailMembership.value) {
loadDetailMembership(detailMember.value!.userId).then(() => {
initEditForm()
})
} else {
initEditForm()
}
}
}
function initEditForm() {
const m = detailMembership.value
const types = editCardTypes.value
if (m) {
// 有卡:预填充
const idx = types.findIndex((t) => t.id === m.cardTypeId)
editForm.value = {
cardTypeIndex: idx >= 0 ? idx : 0,
cardTypeId: m.cardTypeId,
remainingTimes: m.remainingTimes,
startDate: m.startDate.slice(0, 10),
expireDate: m.expireDate.slice(0, 10),
manuallyEditedExpire: false,
}
} else {
// 无卡:默认选中第一个卡种
editForm.value = {
cardTypeIndex: 0,
cardTypeId: types[0]?.id || '',
remainingTimes: types[0]?.totalTimes ?? null,
startDate: formatDate2(new Date()),
expireDate: calculateExpireDate(formatDate2(new Date()), types[0]?.durationDays ?? 30),
manuallyEditedExpire: false,
}
}
}
function formatDate2(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
function calculateExpireDate(startDate: string, durationDays: number): string {
const d = new Date(startDate)
d.setDate(d.getDate() + durationDays)
return formatDate2(d)
}
function onCardTypeChange(e: { detail: { value: number } }) {
const idx = e.detail.value
const cardType = editCardTypes.value[idx]
editForm.value.cardTypeIndex = idx
editForm.value.cardTypeId = cardType.id
// 自动填充次数和有效期(仅当用户未手动修改过有效期时)
if (cardType.totalTimes != null) {
editForm.value.remainingTimes = cardType.totalTimes
}
if (!editForm.value.manuallyEditedExpire) {
editForm.value.startDate = formatDate2(new Date())
editForm.value.expireDate = calculateExpireDate(
formatDate2(new Date()),
cardType.durationDays,
)
}
}
function onStartDateChange(e: { detail: { value: string } }) {
editForm.value.startDate = e.detail.value
if (!editForm.value.manuallyEditedExpire) {
const cardType = editCardTypes.value[editForm.value.cardTypeIndex]
if (cardType) {
editForm.value.expireDate = calculateExpireDate(e.detail.value, cardType.durationDays)
}
}
}
function onExpireDateChange(e: { detail: { value: string } }) {
editForm.value.expireDate = e.detail.value
editForm.value.manuallyEditedExpire = true
}
function goEdit() {
switchTab('edit')
}
async function onSaveMembership() {
if (editSubmitting.value) return
const userId = detailMember.value?.userId
if (!userId) return
editSubmitting.value = true
try {
await adminStore.updateUserMembership(userId, {
cardTypeId: editForm.value.cardTypeId,
remainingTimes: editForm.value.remainingTimes,
startDate: editForm.value.startDate,
expireDate: editForm.value.expireDate,
})
uni.showToast({ title: '保存成功', icon: 'success' })
activeTab.value = 'detail'
await loadDetailMembership(userId)
// 刷新列表
loadMembers(false)
} catch {
uni.showToast({ title: '保存失败', icon: 'none' })
} finally {
editSubmitting.value = false
}
}
async function onClearMembership() {
const userId = detailMember.value?.userId
if (!userId) return
uni.showModal({
title: '确认解除',
content: '确定要解除该用户的会员卡吗?',
confirmColor: '#e64329',
success: async (res) => {
if (!res.confirm) return
try {
await adminStore.deleteUserMembership(userId)
uni.showToast({ title: '已解除', icon: 'success' })
showDetail.value = false
loadMembers(false)
} catch {
uni.showToast({ title: '操作失败', icon: 'none' })
}
},
})
}
function closeModal() {
showDetail.value = false
activeTab.value = 'detail'
detailMembership.value = null
}
// 修改 openDetail
function openDetail(m: MemberSummary) {
detailMember.value = m
showDetail.value = true
detailMembership.value = null // 重置,懒加载
activeTab.value = 'detail'
// 预加载 membership详情 Tab 需要显示)
loadDetailMembership(m.userId)
}
```
#### 5.3 样式改动
`<style>` 末尾添加以下样式:
```scss
/* ── Modal header ─────────────────────────── */
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 0 32rpx 0;
}
.modal-title {
font-size: 32rpx;
font-weight: 700;
color: $brand-color;
}
.modal-close-btn {
font-size: 48rpx;
color: $text-hint;
line-height: 1;
}
/* ── Tab bar ───────────────────────────────── */
.tab-bar {
display: flex;
border-bottom: 1rpx solid $border-color;
margin-bottom: 32rpx;
}
.tab-item {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding-bottom: 16rpx;
position: relative;
}
.tab-item--active::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 48rpx;
height: 4rpx;
background: $accent-color;
border-radius: 2rpx;
}
.tab-text {
font-size: 28rpx;
color: $text-hint;
}
.tab-item--active .tab-text {
color: $accent-color;
font-weight: 600;
}
.tab-content {
min-height: 400rpx;
}
/* ── Membership card ────────────────────────── */
.membership-card {
background: linear-gradient(135deg, rgba($brand-color, 0.08), rgba($accent-color, 0.08));
border: 1rpx solid rgba($brand-color, 0.15);
border-radius: $radius-md;
padding: 24rpx;
margin-bottom: 24rpx;
}
.membership-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
}
.membership-card-name {
font-size: 30rpx;
font-weight: 700;
color: $brand-color;
}
.membership-card-badge {
font-size: 20rpx;
padding: 4rpx 12rpx;
border-radius: 8rpx;
}
.badge--ACTIVE { background: rgba(#52c41a, 0.12); color: #52c41a; }
.badge--EXPIRED { background: rgba($text-hint, 0.12); color: $text-hint; }
.badge--USED_UP { background: rgba(#faad14, 0.12); color: #faad14; }
.membership-card-info {
display: flex;
flex-direction: column;
gap: 8rpx;
}
.membership-info-item {
font-size: 24rpx;
color: $text-secondary;
}
/* ── No membership ──────────────────────────── */
.no-membership {
display: flex;
flex-direction: column;
align-items: center;
padding: 48rpx 0;
gap: 24rpx;
}
.no-membership-text {
font-size: 28rpx;
color: $text-hint;
}
.no-membership-btn {
background: $brand-color;
border-radius: 36rpx;
padding: 16rpx 48rpx;
}
.no-membership-btn-text {
font-size: 26rpx;
font-weight: 600;
color: $accent-color;
}
/* ── Danger zone ───────────────────────────── */
.danger-zone {
margin-top: 24rpx;
padding-top: 24rpx;
border-top: 1rpx solid $border-color;
}
.danger-btn {
width: 100%;
height: 80rpx;
background: rgba(230, 67, 41, 0.08);
border-radius: 40rpx;
display: flex;
align-items: center;
justify-content: center;
}
.danger-btn-text {
font-size: 26rpx;
color: #e64329;
}
/* ── Edit form ─────────────────────────────── */
.edit-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 80rpx 0;
}
.edit-loading-text {
font-size: 26rpx;
color: $text-hint;
}
.edit-form {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.form-item {
display: flex;
align-items: center;
gap: 20rpx;
}
.form-label {
font-size: 26rpx;
color: $text-secondary;
width: 140rpx;
flex-shrink: 0;
}
.form-input {
flex: 1;
height: 72rpx;
background: $bg-page;
border-radius: $radius-sm;
padding: 0 20rpx;
font-size: 26rpx;
color: $text-primary;
}
.form-picker {
flex: 1;
height: 72rpx;
background: $bg-page;
border-radius: $radius-sm;
display: flex;
align-items: center;
}
.form-picker-inner {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20rpx;
}
.form-picker-text {
font-size: 26rpx;
color: $text-primary;
}
.form-picker-arrow {
font-size: 20rpx;
color: $text-hint;
}
.edit-submit {
margin-top: 16rpx;
}
.submit-btn {
width: 100%;
height: 88rpx;
background: $brand-color;
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
}
.submit-btn--disabled {
opacity: 0.5;
}
.submit-btn-text {
font-size: 28rpx;
font-weight: 600;
color: $accent-color;
}
```
---
## 自检清单
- [ ] 后端接口路径正确:`/admin/members/:userId/membership`(不是 `/admin/membership/...`
- [ ] `UpdateUserMembershipDto` 字段与前端 `editForm` 一致
- [ ] `isTimeBasedCard` 计算逻辑:`cardType.type === 'TIMES' || cardType.type === 'TRIAL'`
- [ ] `detailMembership``openDetail` 时懒加载,不阻塞弹窗打开
- [ ] `deleteUserMembership` 是软删除status → EXPIRED不是物理删除
- [ ] `switchTab('edit')` 时 initEditForm 使用 `detailMembership.value`(可能为 null
- [ ] 卡种 picker 变化时,`remainingTimes` 只对 TIMES/TRIAL 类型生效

View File

@@ -0,0 +1,186 @@
# 会员卡编辑功能设计
**日期:** 2026-04-07
**状态:** 已批准
---
## 1. 概述
在管理后台会员列表中,点击会员 item 时弹出的详情弹窗增加"编辑"能力。管理员可编辑指定用户的卡种、卡有效期、剩余次数,也可清空/解除会员卡。
**前置约束:**
- 一个用户只能拥有一张会员卡
- 支持清空卡(解除会员资格)
---
## 2. 交互设计
### 2.1 弹窗结构
```
┌─────────────────────────────────┐
│ 会员详情 [×] │
├──────────┬──────────────────────┤
│ 详情 │ 编辑 │ ← Tab 切换
├──────────┴──────────────────────┤
│ 头像 / 昵称 / OpenID / 手机号 │
│ ───────────────────────────── │
│ 会员卡信息区(根据 Tab 显示) │
│ 预约统计(总/完成/取消) │
│ ───────────────────────────── │
│ [关闭] │
└─────────────────────────────────┘
```
### 2.2 无卡用户交互
- 详情 Tab 显示"暂无会员卡"提示
- 显示"去开卡"按钮,点击切换到编辑 Tab
- 编辑 Tab 中卡种 picker 默认选中列表第一项,有效期自动计算
### 2.3 状态说明
| 场景 | Tab 初始显示 | 编辑表单状态 |
|------|-------------|-------------|
| 有卡用户 | 详情 Tab | 预填充当前数据 |
| 无卡用户 | 详情 Tab提示无卡 | 空白表单,卡种默认选中第一项 |
---
## 3. 表单字段
| 字段 | 组件 | 条件 | 说明 |
|------|------|------|------|
| 卡种 | picker | 必填 | 从 `CardType` 表读取,按 sortOrder 排序 |
| 剩余次数 | input (number) | TIMES / TRIAL 类型 | 默认填充卡种的 totalTimes |
| 生效日期 | date picker | 必填 | 默认当天 |
| 有效期至 | date picker | 必填 | 自动计算,支持手动调整 |
### 3.1 卡种切换逻辑
切换卡种时:
1. `remainingTimes` 自动填充新卡种的 `totalTimes`TIMES/TRIAL 类型)
2. `expireDate` 按新卡种 `durationDays` 重新计算起始日期
3. 编辑器内手动修改过 `expireDate` 时,不再自动覆盖
---
## 4. API 设计
### 4.1 获取用户会员卡
```
GET /admin/members/:userId/membership
```
**响应:**
```json
{
"userId": "uuid",
"membership": {
"id": "uuid",
"cardTypeId": "uuid",
"remainingTimes": 10,
"startDate": "2026-01-01",
"expireDate": "2026-04-01",
"status": "ACTIVE",
"cardType": { ... }
} | null
}
```
### 4.2 创建或更新会员卡
```
PUT /admin/members/:userId/membership
```
**请求体:**
```json
{
"cardTypeId": "uuid",
"remainingTimes": 10,
"startDate": "2026-04-07",
"expireDate": "2026-07-07"
}
```
**业务逻辑:**
- 若该用户已有 membership → 更新
- 若该用户无 membership → 创建新的
- `status` 由后端根据 expireDate 和 remainingTimes 自动计算
### 4.3 解除会员卡
```
DELETE /admin/members/:userId/membership
```
**业务逻辑:**
- 将 membership 的 `status` 标记为 `EXPIRED`(软删除,便于审计)
- 不物理删除记录
---
## 5. 数据模型
### 5.1 会员卡状态自动计算规则
```
if (expireDate < now) → EXPIRED
else if (remainingTimes === 0) → USED_UP
else → ACTIVE
```
### 5.2 变更日志
通过现有的 `BookingStatusHistory` 表记录操作tbd: 是否需要独立 `MembershipChangeLog` 表,待实现时确认)。
---
## 6. 错误处理
| 场景 | 处理 |
|------|------|
| 卡种不存在 | 后端返回 404前端提示"卡种不存在" |
| 有效期早于生效日期 | 前端表单校验失败,提示"有效期不能早于生效日期" |
| 次数为负数 | 前端表单校验失败,提示"次数不能为负数" |
| 网络错误 | Toast 提示"网络错误,请重试" |
| 清空卡确认 | 弹出确认对话框,提示"确定要解除该用户的会员卡吗?" |
---
## 7. 涉及改动
### 后端
- `packages/server/src/membership/membership.controller.ts` — 新增三个 admin 接口
- `packages/server/src/membership/membership.service.ts` — 新增 `getUserMembership` / `updateUserMembership` / `deleteUserMembership`
- `packages/server/src/user/user.controller.ts` — 无改动(列表接口保持不变)
### 前端
- `packages/app/src/stores/admin.ts` — 新增三个 store action
- `packages/app/src/pages/admin/members.vue` — 将详情弹窗改造为 Tab 模式,新增编辑表单
---
## 8. UI 细节
### 8.1 Tab 切换
- 详情/编辑 Tab 横向排列,激活态有下划线指示
- 无卡用户点击"去开卡"后,自动切到编辑 Tab
### 8.2 编辑表单提交
- 保存成功后 Toast 提示"保存成功"
- 自动切回详情 Tab 并刷新数据
- 保存按钮点击后置灰,防止重复提交
### 8.3 清空卡
- 需二次确认
- 成功后弹窗关闭,列表自动刷新

View File

@@ -0,0 +1,298 @@
<template>
<view class="about-section">
<view class="section-header">
<view>
<text class="section-eyebrow">Teacher Spotlight</text>
<text class="section-title">老师介绍</text>
</view>
<text class="section-more" @tap="goToDetail">查看详情</text>
</view>
<view class="teacher-card" @tap="goToDetail">
<view class="teacher-main">
<view class="cover-wrap">
<image class="teacher-cover" :src="teacher.avatar" mode="aspectFill" />
<view class="cover-badge">
<text class="cover-badge-text">{{ teacher.badges[0] }}</text>
</view>
</view>
<view class="teacher-content">
<view class="teacher-heading">
<view>
<view class="name-row">
<text class="teacher-name">{{ teacher.name }}</text>
<text class="teacher-tag">{{ teacher.badges[1] }}</text>
</view>
<text class="teacher-title">{{ teacher.title }}</text>
</view>
</view>
<view class="specialty-row">
<text v-for="item in teacher.specialties" :key="item" class="specialty-pill">{{ item }}</text>
</view>
<text class="teacher-intro">{{ teacher.intro }}</text>
</view>
</view>
<view class="credential-box">
<text class="credential-label">认证背景</text>
<view v-for="item in teacher.certifications" :key="item" class="credential-row">
<view class="credential-dot" />
<text class="credential-text">{{ item }}</text>
</view>
</view>
<view class="action-row">
<view class="secondary-btn" @tap.stop="goToDetail">
<text class="secondary-btn-text">查看详情</text>
</view>
<view class="primary-btn" @tap.stop="goToBooking">
<text class="primary-btn-text">立即预约</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { irisProfile } from '../utils/teacher'
const teacher = irisProfile
function goToDetail() {
uni.navigateTo({ url: `/pages/teacher/detail?id=${teacher.id}` })
}
function goToBooking() {
uni.switchTab({ url: '/pages/booking/index' })
}
</script>
<style lang="scss" scoped>
.about-section {
padding: 20rpx 24rpx 0;
}
.section-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 18rpx;
}
.section-eyebrow {
display: block;
font-size: 20rpx;
letter-spacing: 3rpx;
text-transform: uppercase;
color: #b99b8c;
margin-bottom: 8rpx;
}
.section-title {
font-size: 34rpx;
font-weight: 700;
color: #2f2723;
}
.section-more {
font-size: 24rpx;
color: #c36d52;
padding: 10rpx 0;
}
.teacher-card {
display: flex;
flex-direction: column;
gap: 18rpx;
background:
radial-gradient(circle at top right, rgba(226, 198, 179, 0.42), transparent 32%),
linear-gradient(135deg, #fffdfb 0%, #f8f2ee 46%, #f2e8e2 100%);
border-radius: 30rpx;
padding: 22rpx;
box-shadow: 0 18rpx 38rpx rgba(126, 98, 84, 0.09);
}
.teacher-main {
display: flex;
gap: 22rpx;
align-items: stretch;
}
.cover-wrap {
position: relative;
width: 188rpx;
height: 248rpx;
border-radius: 24rpx;
overflow: hidden;
flex-shrink: 0;
background: #eadfd7;
}
.teacher-cover {
width: 100%;
height: 100%;
}
.cover-badge {
position: absolute;
left: 14rpx;
bottom: 14rpx;
border-radius: 999rpx;
padding: 8rpx 16rpx;
background: rgba(41, 34, 30, 0.74);
backdrop-filter: blur(12rpx);
}
.cover-badge-text {
font-size: 20rpx;
font-weight: 600;
color: #fff7f3;
}
.teacher-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.teacher-heading {
margin-bottom: 14rpx;
}
.name-row {
display: flex;
align-items: center;
gap: 10rpx;
margin-bottom: 8rpx;
}
.teacher-name {
font-size: 38rpx;
line-height: 1;
font-weight: 700;
color: #2e2521;
}
.teacher-tag {
font-size: 18rpx;
line-height: 1;
color: #a85d44;
background: rgba(221, 150, 118, 0.18);
border-radius: 999rpx;
padding: 8rpx 12rpx;
}
.teacher-title {
font-size: 24rpx;
color: #7f6659;
}
.specialty-row {
display: flex;
flex-wrap: wrap;
gap: 10rpx;
margin-bottom: 14rpx;
}
.specialty-pill {
padding: 8rpx 16rpx;
border-radius: 999rpx;
background: rgba(92, 126, 151, 0.12);
color: #5a7a8b;
font-size: 20rpx;
font-weight: 600;
}
.teacher-intro {
font-size: 22rpx;
line-height: 1.7;
color: #5e5048;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.credential-box {
padding: 16rpx 18rpx;
border-radius: 18rpx;
background: rgba(255, 255, 255, 0.72);
border: 1rpx solid rgba(190, 161, 145, 0.22);
}
.credential-label {
display: block;
font-size: 18rpx;
letter-spacing: 2rpx;
color: #b19486;
margin-bottom: 8rpx;
}
.credential-text {
font-size: 20rpx;
line-height: 1.7;
color: #6a5a51;
}
.credential-row {
display: flex;
align-items: flex-start;
gap: 12rpx;
}
.credential-row + .credential-row {
margin-top: 12rpx;
}
.credential-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
margin-top: 12rpx;
flex-shrink: 0;
background: #6d4037;
}
.action-row {
display: flex;
gap: 12rpx;
}
.secondary-btn,
.primary-btn {
height: 72rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
}
.secondary-btn {
width: 148rpx;
border: 1rpx solid rgba(139, 113, 99, 0.24);
background: rgba(255, 255, 255, 0.8);
}
.primary-btn {
flex: 1;
background: linear-gradient(135deg, #ff7654 0%, #ff4d38 100%);
box-shadow: 0 14rpx 24rpx rgba(255, 92, 69, 0.24);
}
.secondary-btn-text {
font-size: 24rpx;
font-weight: 600;
color: #684d40;
}
.primary-btn-text {
font-size: 26rpx;
font-weight: 700;
color: #ffffff;
letter-spacing: 1rpx;
}
</style>

View File

@@ -105,13 +105,13 @@
<view class="btn-outline" @tap="handleCancel">
<text class="btn-outline-text">取消</text>
</view>
<view
<button
class="btn-confirm"
:class="{ disabled: !selectedMembershipId }"
@tap="handleConfirm"
>
<text class="btn-confirm-text">确认预约</text>
</view>
</button>
</view>
</view>
</view>
@@ -120,6 +120,7 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pilates/shared'
import { requestBookingCreatedSubscriptionMessage } from '../utils/wechat-subscription'
const props = defineProps<{
visible: boolean
@@ -134,6 +135,7 @@ const emit = defineEmits<{
}>()
const selectedMembershipId = ref<string>('')
const requestingSubscribe = ref(false)
// Auto-select the first membership when popup opens or memberships list changes
watch(
@@ -150,8 +152,22 @@ const selectedMembership = computed(() =>
props.memberships.find((m) => m.id === selectedMembershipId.value) ?? null,
)
function handleConfirm() {
async function handleConfirm() {
if (!props.timeSlot || !selectedMembershipId.value) return
if (requestingSubscribe.value) return
requestingSubscribe.value = true
try {
await requestBookingCreatedSubscriptionMessage()
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '订阅消息授权失败'
uni.showToast({ title: message, icon: 'none' })
return
} finally {
requestingSubscribe.value = false
}
emit('confirm', {
timeSlotId: props.timeSlot.id,
membershipId: selectedMembershipId.value,

View File

@@ -1,51 +1,49 @@
<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 -->
<view class="logo-circle">
<image
v-if="studioInfo?.logo"
v-if="logoImage"
class="logo-img"
:src="studioInfo.logo"
mode="aspectFit"
:src="logoImage"
mode="aspectFill"
/>
<text v-else class="logo-placeholder">FC</text>
<view v-else class="logo-placeholder">
<text>{{ studioName.slice(0, 1) || 'F' }}</text>
</view>
</view>
<!-- Studio name -->
<text class="studio-name">{{ studioInfo?.name || 'Focus Core' }}</text>
<text class="studio-name">{{ studioName }}</text>
</view>
</view>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { computed } from 'vue'
import type { StudioConfig } from '@mp-pilates/shared'
import { getSystemLayout } from '../utils/system'
defineProps<{
const props = defineProps<{
studioInfo: StudioConfig | null
}>()
const statusBarHeight = ref(0)
const fallbackBannerImage = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/images/bannerBg.jpg'
const fallbackLogoImage = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/images/logo.jpg'
onMounted(() => {
statusBarHeight.value = getSystemLayout().statusBarHeight
})
const bannerImage = computed(() => props.studioInfo?.bannerUrl || fallbackBannerImage)
const logoImage = computed(() => props.studioInfo?.logo || fallbackLogoImage)
const studioName = computed(() => props.studioInfo?.name || 'Focus Core')
</script>
<style lang="scss" scoped>
@@ -63,6 +61,8 @@ onMounted(() => {
left: 0;
width: 100%;
height: 100%;
filter: blur(2px);
transform: scale(1.05);
}
.banner-overlay {
@@ -74,11 +74,6 @@ onMounted(() => {
background: rgba($primary-dark, 0.25);
}
.status-bar {
position: relative;
z-index: 2;
}
.banner-content {
position: relative;
z-index: 2;
@@ -86,7 +81,7 @@ onMounted(() => {
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 40rpx;
padding-top: 120rpx;
}
.logo-circle {
@@ -102,15 +97,22 @@ onMounted(() => {
}
.logo-img {
width: 160rpx;
height: 160rpx;
width: 200rpx;
height: 200rpx;
border-radius: 50%;
}
.logo-placeholder {
width: 200rpx;
height: 200rpx;
border-radius: 50%;
font-size: 64rpx;
font-weight: 800;
color: #333;
letter-spacing: 4rpx;
display: flex;
align-items: center;
justify-content: center;
}
.studio-name {

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,45 @@
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>
<!-- Card Cover image if available, gradient fallback -->
<view class="card-cover" :class="card.coverUrl ? '' : getCardCoverClass(card.type)">
<image
v-if="card.coverUrl"
class="card-cover-img"
:src="card.coverUrl"
mode="aspectFill"
/>
<template v-else>
<view class="cover-deco cover-deco--1" />
<view class="cover-deco cover-deco--2" />
</template>
</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>
<!-- 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>
<text
v-if="card.originalPrice && card.originalPrice > card.price"
class="price-original"
>
原价:¥{{ formatPrice(card.originalPrice) }}
</text>
</view>
</view>
<!-- Arrow -->
<text class="card-arrow"></text>
</view>
</view>
@@ -67,22 +82,30 @@
<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, getCardCoverClass } from '../utils/format'
const cardTypes = ref<CardType[]>([])
const loading = ref(false)
const hasLoaded = ref(false)
async function fetchCardTypes() {
loading.value = true
// Stale-While-Revalidate: only show skeleton on first load
// Subsequent refreshes silently update data in background
if (!hasLoaded.value) {
loading.value = true
}
try {
const result = await get<CardType[]>('/membership/card-types')
cardTypes.value = result
.filter((c) => c.isActive)
.sort((a, b) => a.sortOrder - b.sortOrder)
hasLoaded.value = true
} catch {
uni.showToast({ title: '加载会员卡失败', icon: 'none' })
// Only show error toast on first load; silent fail on background refresh
if (!hasLoaded.value) {
uni.showToast({ title: '加载会员卡失败', icon: 'none' })
}
} finally {
loading.value = false
}
@@ -98,26 +121,18 @@ 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>
.card-shop {
background: #ffffff;
margin-top: 16rpx;
margin: 16rpx 24rpx 0;
padding-bottom: 20rpx;
border-radius: 20rpx;
overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
}
/* ── Section header ── */
@@ -150,132 +165,170 @@ 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 {
/* ══════════════════════════════════════════════════════════
CARD COVER — Clean minimal design
══════════════════════════════════════════════════════════ */
.card-cover {
width: 200rpx;
height: 140rpx;
border-radius: 12rpx;
height: 130rpx;
border-radius: 16rpx;
overflow: hidden;
flex-shrink: 0;
position: relative;
}
.thumb-fallback {
.card-cover-img {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8rpx;
padding: 12rpx;
}
.thumb--times .thumb-fallback {
background: linear-gradient(135deg, #3a3a3a, #555);
/* Decorative circles */
.cover-deco {
position: absolute;
border-radius: 50%;
pointer-events: none;
&--1 {
width: 100rpx;
height: 100rpx;
top: -30rpx;
right: -20rpx;
background: rgba(255, 255, 255, 0.4);
}
&--2 {
width: 70rpx;
height: 70rpx;
bottom: -20rpx;
left: -10rpx;
background: rgba(255, 255, 255, 0.25);
}
}
.thumb--duration .thumb-fallback {
background: linear-gradient(135deg, #6c3483, #9b59b6);
/* Card cover backgrounds */
.cover--times {
background: linear-gradient(135deg, #E8D5C4 0%, #D4BFA8 100%);
}
.thumb--trial .thumb-fallback {
background: linear-gradient(135deg, #5a7a8a, $primary-dark);
.cover--duration {
background: linear-gradient(135deg, #D8C8DC 0%, #C4AECB 100%);
}
.thumb-name {
font-size: 22rpx;
font-weight: 600;
color: #ffffff;
text-align: center;
line-height: 1.3;
word-break: break-all;
.cover--trial {
background: linear-gradient(135deg, #C8D8D2 0%, #A9C4BC 100%);
}
.thumb-price {
font-size: 24rpx;
font-weight: 700;
color: #ffffff;
}
/* ── Card info ── */
/* ── 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 {
display: block;
font-size: 30rpx;
font-weight: 600;
color: #222;
margin-bottom: 8rpx;
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;
}
.price-row {
.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 +343,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 +357,7 @@ function truncate(str: string, maxLen: number): string {
}
&--price {
width: 50%;
width: 45%;
height: 36rpx;
}
}
@@ -319,6 +372,6 @@ function truncate(str: string, maxLen: number): string {
.empty-text {
font-size: 28rpx;
color: #bbb;
color: $text-hint;
}
</style>

View File

@@ -39,7 +39,14 @@ onMounted(() => {
})
function handleBack() {
uni.navigateBack({ delta: 1 })
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack({ delta: 1 })
return
}
uni.switchTab({ url: '/pages/home/index' })
}
</script>

View File

@@ -1,12 +1,15 @@
<template>
<view class="date-selector">
<view class="date-selector" :class="`date-selector--${variant}`">
<scroll-view class="scroll" scroll-x enhanced :show-scrollbar="false">
<view class="track">
<view
v-for="item in dateRange"
:key="item.date"
class="date-item"
:class="{ active: item.date === modelValue, today: item.isToday }"
:class="[
`date-item--${variant}`,
{ active: item.date === modelValue, today: item.isToday },
]"
@tap="handleSelect(item.date)"
>
<text class="weekday">{{ item.isToday ? '今天' : item.weekday }}</text>
@@ -25,6 +28,7 @@ import { getDateRange } from '../utils/format'
interface Props {
modelValue: string
variant?: 'default' | 'booking'
}
const props = defineProps<Props>()
@@ -47,6 +51,8 @@ function handleSelect(date: string) {
emit('update:modelValue', date)
emit('select', date)
}
const variant = computed(() => props.variant ?? 'default')
</script>
<style lang="scss" scoped>
@@ -55,6 +61,11 @@ function handleSelect(date: string) {
padding: 16rpx 0 20rpx;
border-bottom: 1rpx solid $primary-border;
&.date-selector--booking {
background: rgba(252, 250, 248, 0.96);
border-bottom-color: rgba(192, 154, 137, 0.12);
}
.scroll {
width: 100%;
white-space: nowrap;
@@ -121,6 +132,40 @@ function handleSelect(date: string) {
font-weight: 600;
}
}
&.date-item--booking {
background: rgba(247, 242, 238, 0.88);
border: 1rpx solid rgba(192, 154, 137, 0.08);
.weekday {
color: #9d8b83;
}
.day {
color: #3a2e2a;
}
.month {
color: #b7a79f;
}
&.active {
background: linear-gradient(135deg, #d7beb1, #b98f7d);
box-shadow: 0 12rpx 28rpx rgba(143, 103, 89, 0.16);
.weekday,
.day,
.month {
color: #fffaf7;
}
}
&.today:not(.active) {
.weekday {
color: #8f6759;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,438 @@
<template>
<view v-if="flashSales.length" class="flash-sale-section">
<!-- Section header -->
<view class="section-header">
<view class="header-left">
<view class="flash-icon-wrap">
<view class="flash-icon-clock" />
</view>
<text class="section-title">限时秒杀</text>
<view v-if="hasOngoing" class="live-dot" />
</view>
</view>
<!-- Horizontal scroll cards -->
<scroll-view
scroll-x
:show-scrollbar="false"
class="flash-scroll"
>
<view class="flash-card-list">
<view
v-for="sale in flashSales"
:key="sale.id"
class="flash-card"
:class="cardPhaseClass(sale.phase)"
@tap="goToDetail(sale.id)"
>
<!-- Top gradient band -->
<view class="card-top">
<!-- Phase badge -->
<view class="phase-badge" :class="badgeClass(sale.phase)">
<text class="phase-badge-text">{{ phaseLabel(sale.phase) }}</text>
</view>
<!-- Countdown / status text -->
<view class="countdown-row">
<text v-if="sale.phase === FlashSalePhase.UPCOMING" class="countdown-label">距开始</text>
<text v-else-if="sale.phase === FlashSalePhase.ONGOING" class="countdown-label">剩余</text>
<view v-if="sale.phase === FlashSalePhase.UPCOMING || sale.phase === FlashSalePhase.ONGOING" class="countdown-blocks">
<text class="cd-block">{{ getSaleCountdown(sale).h }}</text>
<text class="cd-sep">:</text>
<text class="cd-block">{{ getSaleCountdown(sale).m }}</text>
<text class="cd-sep">:</text>
<text class="cd-block">{{ getSaleCountdown(sale).s }}</text>
</view>
</view>
</view>
<!-- Card body -->
<view class="card-body">
<text class="card-title">{{ sale.title }}</text>
<text class="card-type-name">{{ sale.cardType.name }}</text>
<!-- Price area -->
<view class="price-area">
<view class="flash-price-row">
<text class="flash-currency">¥</text>
<text class="flash-price">{{ formatPrice(sale.flashPrice) }}</text>
</view>
<text class="original-price">¥{{ formatPrice(sale.originalPrice) }}</text>
</view>
<!-- Stock progress -->
<view class="stock-area">
<view class="stock-bar">
<view
class="stock-fill"
:class="{ 'stock-fill--hot': getStockRatio(sale.soldCount, sale.totalStock) > 0.6 }"
:style="{ width: stockPercent(sale) }"
/>
</view>
<text class="stock-text">
{{ sale.phase === FlashSalePhase.SOLD_OUT ? '已售罄' : `${sale.remainingStock}/${sale.totalStock}` }}
</text>
</view>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { FlashSalePhase } from '@mp-pilates/shared'
import type { FlashSaleListItem } from '@mp-pilates/shared'
import { formatPrice, getFlashSalePhaseLabel, getCountdownParts, getStockRatio, getStockPercent } from '../utils/format'
import { get } from '../utils/request'
const flashSales = ref<FlashSaleListItem[]>([])
const tick = ref(0)
let timer: ReturnType<typeof setInterval> | null = null
const hasOngoing = computed(() =>
flashSales.value.some((s) => s.phase === FlashSalePhase.ONGOING),
)
async function fetchFlashSales() {
try {
const data = await get<FlashSaleListItem[]>('/flash-sales')
flashSales.value = [...data]
} catch {
flashSales.value = []
}
}
// Expose for parent page refresh
defineExpose({ fetchFlashSales })
function phaseLabel(phase: FlashSalePhase): string {
return getFlashSalePhaseLabel(phase)
}
function cardPhaseClass(phase: FlashSalePhase): string {
if (phase === FlashSalePhase.ONGOING) return 'card--ongoing'
if (phase === FlashSalePhase.UPCOMING) return 'card--upcoming'
if (phase === FlashSalePhase.SOLD_OUT) return 'card--soldout'
return 'card--ended'
}
function badgeClass(phase: FlashSalePhase): string {
if (phase === FlashSalePhase.ONGOING) return 'badge--ongoing'
if (phase === FlashSalePhase.UPCOMING) return 'badge--upcoming'
return 'badge--inactive'
}
function stockPercent(sale: FlashSaleListItem): string {
return getStockPercent(sale.soldCount, sale.totalStock)
}
function getSaleCountdown(sale: FlashSaleListItem) {
void tick.value
const target = sale.phase === FlashSalePhase.UPCOMING ? sale.startTime : sale.endTime
return getCountdownParts(target)
}
function goToDetail(id: string) {
uni.navigateTo({ url: `/pages/flash-sale/detail?id=${id}` })
}
onMounted(() => {
fetchFlashSales()
timer = setInterval(() => {
tick.value++
}, 1000)
})
onUnmounted(() => {
if (timer) {
clearInterval(timer)
timer = null
}
})
</script>
<style lang="scss" scoped>
.flash-sale-section {
background: #fff;
margin: 16rpx 24rpx 0;
padding-bottom: 24rpx;
border-radius: 20rpx;
overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
}
/* ── Section header ── */
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28rpx 32rpx 16rpx;
}
.header-left {
display: flex;
align-items: center;
gap: 12rpx;
}
.flash-icon-wrap {
width: 44rpx;
height: 44rpx;
border-radius: 12rpx;
background: linear-gradient(135deg, #D4A59A, #C08B7E);
display: flex;
align-items: center;
justify-content: center;
}
/* CSS-drawn clock icon */
.flash-icon-clock {
width: 24rpx;
height: 24rpx;
border: 3rpx solid #fff;
border-radius: 50%;
position: relative;
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 2rpx;
height: 9rpx;
background: #fff;
transform-origin: bottom center;
transform: translate(-50%, -100%) rotate(0deg);
border-radius: 1rpx;
}
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 2rpx;
height: 7rpx;
background: #fff;
transform-origin: bottom center;
transform: translate(-50%, -100%) rotate(90deg);
border-radius: 1rpx;
}
}
.section-title {
font-size: 36rpx;
font-weight: 700;
color: #222;
}
.live-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: #C08B7E;
animation: pulse 1.5s ease infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.8); }
}
/* ── Horizontal scroll ── */
.flash-scroll {
padding-left: 32rpx;
white-space: nowrap;
}
.flash-card-list {
display: inline-flex;
gap: 20rpx;
padding-right: 32rpx;
}
/* ── Flash card ── */
.flash-card {
width: 340rpx;
border-radius: 20rpx;
overflow: hidden;
flex-shrink: 0;
display: inline-flex;
flex-direction: column;
box-shadow: 0 4rpx 20rpx rgba(192, 139, 126, 0.18);
white-space: normal;
}
.card--ongoing { box-shadow: 0 6rpx 28rpx rgba(192, 139, 126, 0.28); }
.card--upcoming { opacity: 0.95; }
.card--soldout { opacity: 0.7; }
.card--ended { opacity: 0.5; }
/* Card top gradient — warm blush tones */
.card-top {
padding: 20rpx 20rpx 16rpx;
background: linear-gradient(135deg, #D4A59A 0%, #C9948A 40%, #B5836E 100%);
display: flex;
flex-direction: column;
gap: 10rpx;
}
.card--upcoming .card-top {
background: linear-gradient(135deg, #8FA89A 0%, #7BA5A0 100%);
}
.card--soldout .card-top,
.card--ended .card-top {
background: linear-gradient(135deg, #C4BAB0, #AEA49A);
}
/* Phase badge */
.phase-badge {
align-self: flex-start;
padding: 4rpx 14rpx;
border-radius: 12rpx;
}
.badge--ongoing { background: rgba(255, 255, 255, 0.3); }
.badge--upcoming { background: rgba(255, 255, 255, 0.25); }
.badge--inactive { background: rgba(0, 0, 0, 0.1); }
.phase-badge-text {
font-size: 20rpx;
font-weight: 600;
color: #fff;
}
/* Countdown */
.countdown-row {
display: flex;
align-items: center;
gap: 8rpx;
}
.countdown-label {
font-size: 20rpx;
color: rgba(255, 255, 255, 0.85);
}
.countdown-blocks {
display: flex;
align-items: center;
gap: 4rpx;
}
.cd-block {
background: rgba(255, 255, 255, 0.25);
color: #fff;
font-size: 22rpx;
font-weight: 700;
padding: 4rpx 8rpx;
border-radius: 6rpx;
font-family: 'DIN Alternate', monospace;
min-width: 36rpx;
text-align: center;
backdrop-filter: blur(4px);
}
.cd-sep {
color: rgba(255, 255, 255, 0.75);
font-size: 20rpx;
font-weight: 700;
}
/* Card body */
.card-body {
padding: 20rpx;
background: #fff;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.card-title {
font-size: 28rpx;
font-weight: 700;
color: $text-primary;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-type-name {
font-size: 22rpx;
color: $text-hint;
}
/* Price area */
.price-area {
display: flex;
align-items: baseline;
gap: 12rpx;
margin-top: 4rpx;
}
.flash-price-row {
display: flex;
align-items: baseline;
}
.flash-currency {
font-size: 22rpx;
font-weight: 700;
color: #B5725E;
}
.flash-price {
font-size: 40rpx;
font-weight: 800;
color: #B5725E;
line-height: 1;
}
.original-price {
font-size: 22rpx;
color: #ccc;
text-decoration: line-through;
}
/* Stock area */
.stock-area {
display: flex;
flex-direction: column;
gap: 6rpx;
margin-top: 8rpx;
}
.stock-bar {
height: 10rpx;
background: #f5f0ed;
border-radius: 5rpx;
overflow: hidden;
}
.stock-fill {
height: 100%;
background: linear-gradient(90deg, #D4A59A, #C08B7E);
border-radius: 5rpx;
transition: width 0.3s;
&--hot {
animation: stockPulse 2s ease infinite;
}
}
@keyframes stockPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.stock-text {
font-size: 20rpx;
color: $text-hint;
}
</style>

View File

@@ -40,28 +40,38 @@ interface MenuItem {
path?: string
isAdmin?: boolean
badge?: string
action?: 'clear' | 'about'
action?: 'clear'
requireAuth?: boolean
}
const props = defineProps<{
isAdmin: boolean
requireAuth?: boolean
activeMembershipCount?: number
upcomingBookingCount?: number
inviteShareEligible?: boolean
}>()
const emit = defineEmits<{
(e: 'clear-cache'): void
(e: 'about'): void
(e: 'require-login'): void
}>()
const menuItems = computed<MenuItem[]>(() => {
const membershipBadge = props.activeMembershipCount && props.activeMembershipCount > 0
? `${props.activeMembershipCount}`
: undefined
const bookingBadge = props.upcomingBookingCount && props.upcomingBookingCount > 0
? `${props.upcomingBookingCount}`
: undefined
const items: MenuItem[] = [
{
key: 'membership',
type: 'item',
title: '我的会员卡',
path: '/pages/profile/membership',
badge: membershipBadge,
requireAuth: true,
},
{
@@ -69,8 +79,28 @@ const menuItems = computed<MenuItem[]>(() => {
type: 'item',
title: '我的预约',
path: '/pages/profile/bookings',
badge: bookingBadge,
requireAuth: true,
},
...(props.isAdmin
? [{
key: 'teaching-schedule',
type: 'item' as const,
title: '我的课表',
path: '/pages/profile/teaching-schedule',
requireAuth: true,
}]
: []),
// 临时隐藏邀请好友入口,后续恢复时直接取消这段注释即可。
// ...(props.inviteShareEligible
// ? [{
// key: 'invite',
// type: 'item' as const,
// title: '邀请好友',
// path: '/pages/profile/invite',
// requireAuth: true,
// }]
// : []),
{
key: 'info',
type: 'item',
@@ -88,12 +118,6 @@ const menuItems = computed<MenuItem[]>(() => {
title: '清除缓存',
action: 'clear',
},
{
key: 'about',
type: 'item',
title: '关于我们',
action: 'about',
},
]
if (props.isAdmin) {
@@ -118,8 +142,6 @@ function handleTap(item: MenuItem) {
}
if (item.action === 'clear') {
emit('clear-cache')
} else if (item.action === 'about') {
emit('about')
} else if (item.path) {
uni.navigateTo({ url: item.path })
}
@@ -224,6 +246,57 @@ function handleTap(item: MenuItem) {
}
}
&--teaching-schedule {
background: rgba(93, 140, 138, 0.12);
&::before {
content: '';
width: 24rpx;
height: 22rpx;
border: 2.5rpx solid #476d72;
border-radius: 6rpx;
box-sizing: border-box;
}
&::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 12rpx;
height: 12rpx;
transform: translate(-30%, -18%) rotate(45deg);
border-top: 2.5rpx solid #476d72;
border-left: 2.5rpx solid #476d72;
}
}
&--invite {
background: rgba(255, 122, 69, 0.12);
&::before {
content: '';
width: 20rpx;
height: 20rpx;
border: 2.5rpx solid #ff7a45;
border-radius: 50%;
position: absolute;
top: 12rpx;
left: 14rpx;
box-sizing: border-box;
box-shadow: 16rpx 8rpx 0 -2rpx rgba(255, 122, 69, 0.95);
}
&::after {
content: '';
position: absolute;
width: 22rpx;
height: 12rpx;
border: 2.5rpx solid #ff7a45;
border-top: none;
border-radius: 0 0 14rpx 14rpx;
left: 17rpx;
bottom: 13rpx;
box-sizing: border-box;
}
}
// 个人信息 — 人形(圆 + 肩弧)
&--info {
background: rgba($brand-color, 0.06);
@@ -279,31 +352,6 @@ function handleTap(item: MenuItem) {
}
}
// 关于我们 — 圆形中心一个点 + 竖线info 标记)
&--about {
background: rgba($text-hint, 0.08);
&::before {
content: '';
width: 22rpx;
height: 22rpx;
border: 2.5rpx solid $text-secondary;
border-radius: 50%;
box-sizing: border-box;
}
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 2.5rpx;
height: 8rpx;
background: $text-secondary;
border-radius: 1rpx;
box-shadow: 0 -6rpx 0 0 $text-secondary;
}
}
// 管理中心 — 齿轮(圆 + 四个刻度)
&--admin {
background: rgba($accent-color, 0.12);
@@ -346,11 +394,17 @@ function handleTap(item: MenuItem) {
&__badge {
font-size: 22rpx;
color: #ffffff;
background: $error-color;
border-radius: 20rpx;
padding: 2rpx 12rpx;
line-height: 1;
font-weight: 600;
color: #8f6759;
background: linear-gradient(135deg, rgba(255, 248, 244, 0.98), rgba(241, 228, 220, 0.96));
border-radius: 999rpx;
padding: 9rpx 18rpx;
margin-right: $spacing-sm;
border: 1rpx solid rgba(192, 154, 137, 0.16);
box-shadow:
inset 0 1rpx 0 rgba(255, 255, 255, 0.92),
0 6rpx 16rpx rgba(143, 103, 89, 0.12);
}
&__arrow {

View File

@@ -1,98 +1,54 @@
<template>
<view class="quick-entry">
<!-- Not logged in -->
<view v-if="!userStore.loggedIn" class="entry-card login-card" @tap="handleLogin">
<view class="entry-content">
<view class="entry-left">
<view class="entry-icon-wrap login-icon">
<view class="icon-user" />
</view>
<view class="entry-text">
<text class="entry-title">欢迎来到工作室</text>
<text class="entry-subtitle">登录后即可预约课程</text>
</view>
</view>
<view class="entry-btn login-btn">
<text class="entry-btn-text">微信登录</text>
</view>
<view v-if="!userStore.loggedIn" class="entry-pill pill-login" @tap="handleLogin">
<view class="pill-dot dot-login" />
<text class="pill-label">欢迎来到工作室</text>
<view class="pill-action action-login">
<text class="pill-action-text">微信登录</text>
</view>
</view>
<!-- Logged in, no memberships at all new user -->
<!-- Logged in, no memberships new user -->
<view
v-else-if="userStore.loggedIn && userStore.memberships.length === 0"
class="entry-card trial-card"
class="entry-pill pill-trial"
@tap="handleTrialEntry"
>
<view class="entry-content">
<view class="entry-left">
<view class="entry-icon-wrap trial-icon">
<view class="icon-star" />
</view>
<view class="entry-text">
<text class="entry-title">初次体验</text>
<text class="entry-subtitle">专属体验课了解普拉提</text>
</view>
</view>
<view class="entry-btn trial-btn">
<text class="entry-btn-text">预约体验课</text>
</view>
<view class="pill-tag tag-trial">体验</view>
<text class="pill-label">首次体验专属课程</text>
<view class="pill-action action-trial">
<text class="pill-action-text">预约体验课</text>
</view>
<view class="card-badge trial-badge">新会员专享</view>
</view>
<!-- Has valid active card + running low warning -->
<!-- Has valid active card -->
<template v-else-if="userStore.hasValidMembership">
<view class="entry-card active-card" @tap="handleBooking">
<view class="entry-content">
<view class="entry-left">
<view class="entry-icon-wrap active-icon">
<view class="icon-clock" />
</view>
<view class="entry-text">
<text class="entry-title">一键约课</text>
<text class="entry-subtitle">{{ activeMembershipLabel }}</text>
</view>
</view>
<view class="entry-btn book-btn">
<text class="entry-btn-text">立即预约</text>
</view>
</view>
<!-- Running low badge -->
<view v-if="isRunningLow" class="card-badge low-badge">
仅剩 {{ lowestRemainingTimes }}
<view class="entry-pill pill-active" @tap="handleBooking">
<view class="pill-dot dot-active" />
<text class="pill-label pill-label-active">{{ activeMembershipLabel }}</text>
<view class="pill-action action-book">
<text class="pill-action-text">约课</text>
</view>
</view>
<!-- Renew reminder if running low -->
<view v-if="isRunningLow" class="renew-tip" @tap="scrollToCardShop">
<view class="renew-tip-icon">
<view class="icon-warning" />
</view>
<text class="renew-tip-text">课次即将用完点击续卡保持练习节奏</text>
<text class="renew-tip-action">续卡 </text>
<!-- Running low: thin accent strip -->
<view v-if="isRunningLow" class="renew-strip" @tap="scrollToCardShop">
<text class="renew-strip-text">仅剩 {{ lowestRemainingTimes }} · 续卡保持节奏</text>
<text class="renew-strip-arrow"></text>
</view>
</template>
<!-- Has memberships but none active buy card -->
<view
v-else
class="entry-card expired-card"
class="entry-pill pill-expired"
@tap="scrollToCardShop"
>
<view class="entry-content">
<view class="entry-left">
<view class="entry-icon-wrap expired-icon">
<view class="icon-card" />
</view>
<view class="entry-text">
<text class="entry-title">续费会员卡</text>
<text class="entry-subtitle">您的卡已到期续卡继续练习</text>
</view>
</view>
<view class="entry-btn renew-btn">
<text class="entry-btn-text">购买会员卡</text>
</view>
<view class="pill-dot dot-expired" />
<text class="pill-label">会员卡已到期</text>
<view class="pill-action action-renew">
<text class="pill-action-text">续卡</text>
</view>
</view>
</view>
@@ -102,6 +58,7 @@
import { computed, ref } from 'vue'
import { useUserStore } from '../stores/user'
import { CardTypeCategory } from '@mp-pilates/shared'
import { getErrorMessage } from '../utils/auth'
const emit = defineEmits<{
(e: 'scroll-to-card-shop'): void
@@ -114,19 +71,15 @@ async function handleLogin() {
if (loading.value) return
loading.value = true
try {
await userStore.login()
await userStore.fetchMemberships()
// 登录成功后跳转到个人中心,让用户完善信息
uni.navigateTo({ url: '/pages/profile/info' })
} catch {
uni.showToast({ title: '登录失败,请重试', icon: 'none' })
await userStore.loginWithSetup()
} catch (err: unknown) {
uni.showToast({ title: getErrorMessage(err, '登录失败,请重试'), icon: 'none' })
} finally {
loading.value = false
}
}
function handleTrialEntry() {
// Navigate to the first TRIAL card detail page
uni.navigateTo({ url: '/pages/card/detail?trial=1' })
}
@@ -138,22 +91,20 @@ function scrollToCardShop() {
emit('scroll-to-card-shop')
}
// Computed: label for the active membership
const activeMembershipLabel = computed(() => {
const active = userStore.activeMemberships
if (!active.length) return ''
const m = active[0]
const cardName = m.cardType.name
if (m.cardType.type === CardTypeCategory.TIMES && m.remainingTimes !== null) {
return `${cardName} · 剩余 ${m.remainingTimes}`
return `${cardName} · 剩余 ${m.remainingTimes}`
}
const expire = new Date(m.expireDate)
const today = new Date()
const daysLeft = Math.ceil((expire.getTime() - today.getTime()) / 86400000)
return `${cardName} · 剩余 ${daysLeft}`
return `${cardName} · 剩余 ${daysLeft}`
})
// Check if any TIMES card has ≤ 2 remaining
const isRunningLow = computed(() => {
return userStore.activeMemberships.some(
(m) =>
@@ -177,295 +128,159 @@ const lowestRemainingTimes = computed(() => {
<style lang="scss" scoped>
.quick-entry {
margin: 24rpx 24rpx 0;
padding: 20rpx 24rpx 0;
}
.entry-card {
position: relative;
border-radius: 16rpx;
padding: 36rpx 32rpx;
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.08);
overflow: hidden;
/* ── Pill base ── */
.entry-pill {
display: flex;
align-items: center;
height: 80rpx;
border-radius: 40rpx;
padding: 0 8rpx 0 24rpx;
gap: 16rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
}
.login-card {
background: linear-gradient(135deg, #1a1a2e 0%, #2d2d5e 100%);
/* ── Pill variants ── */
.pill-login {
background: #1a1a2e;
}
.trial-card {
.pill-trial {
background: linear-gradient(135deg, #2d2d5e 0%, #4a3f7a 100%);
}
.active-card {
background: linear-gradient(135deg, #2a3a4a 0%, #1a2a3a 100%);
.pill-active {
background: #ffffff;
border: 1rpx solid rgba(0, 0, 0, 0.06);
box-shadow: 0 2rpx 16rpx rgba(0, 0, 0, 0.05);
}
.expired-card {
background: linear-gradient(135deg, #4a4a4a 0%, #2a2a2a 100%);
.pill-expired {
background: #f5f5f5;
border: 1rpx solid rgba(0, 0, 0, 0.04);
box-shadow: none;
}
.entry-content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24rpx;
}
.entry-left {
display: flex;
align-items: center;
gap: 28rpx;
flex: 1;
}
.entry-icon-wrap {
width: 88rpx;
height: 88rpx;
/* ── Status dot ── */
.pill-dot {
width: 14rpx;
height: 14rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
position: relative;
}
.login-icon {
background: rgba(255, 255, 255, 0.12);
.dot-login {
background: $primary-color;
box-shadow: 0 0 8rpx rgba($primary-color, 0.6);
}
.trial-icon {
background: rgba(255, 215, 0, 0.2);
.dot-active {
background: #34c759;
box-shadow: 0 0 8rpx rgba(52, 199, 89, 0.5);
}
.active-icon {
background: rgba(168, 196, 206, 0.25);
.dot-expired {
background: #aaa;
}
.expired-icon {
background: rgba(255, 255, 255, 0.12);
}
/* ── Icon shapes (pure CSS) ── */
/* User icon: head + shoulders */
.icon-user {
position: relative;
width: 36rpx;
height: 36rpx;
&::before {
content: '';
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 20rpx;
height: 20rpx;
border-radius: 50%;
background: #fff;
}
&::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 28rpx;
height: 14rpx;
border-radius: 14rpx 14rpx 0 0;
background: #fff;
}
}
/* Star icon - diamond shape */
.icon-star {
position: relative;
width: 32rpx;
height: 32rpx;
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(45deg);
width: 24rpx;
height: 24rpx;
background: #ffd700;
}
}
/* Clock icon - circle with dot */
.icon-clock {
position: relative;
width: 36rpx;
height: 36rpx;
border-radius: 50%;
border: 3rpx solid #fff;
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 8rpx;
height: 8rpx;
border-radius: 50%;
background: #fff;
}
}
/* Card icon */
.icon-card {
position: relative;
width: 36rpx;
height: 26rpx;
border-radius: 4rpx;
border: 3rpx solid #fff;
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 12rpx;
height: 6rpx;
border-radius: 2rpx;
background: #fff;
}
}
/* Warning triangle */
.icon-warning {
position: relative;
width: 0;
height: 0;
border-left: 12rpx solid transparent;
border-right: 12rpx solid transparent;
border-bottom: 20rpx solid #e8a87c;
}
.entry-text {
flex: 1;
min-width: 0;
}
.entry-title {
display: block;
font-size: 34rpx;
font-weight: 600;
color: #ffffff;
margin-bottom: 8rpx;
/* ── Tag (trial only) ── */
.pill-tag {
font-size: 20rpx;
font-weight: 700;
padding: 4rpx 14rpx;
border-radius: 20rpx;
flex-shrink: 0;
letter-spacing: 1rpx;
}
.entry-subtitle {
display: block;
font-size: 24rpx;
color: rgba(255, 255, 255, 0.6);
line-height: 1.4;
.tag-trial {
background: rgba(255, 215, 0, 0.25);
color: #ffd700;
}
.entry-btn {
/* ── Label text ── */
.pill-label {
flex: 1;
font-size: 26rpx;
font-weight: 500;
color: rgba(255, 255, 255, 0.85);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1;
}
.pill-label-active {
color: #333;
}
.pill-expired .pill-label {
color: #888;
}
/* ── Action button ── */
.pill-action {
flex-shrink: 0;
padding: 18rpx 36rpx;
border-radius: 40rpx;
height: 60rpx;
padding: 0 28rpx;
border-radius: 30rpx;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(255,255,255,0.2) 0%, transparent 100%);
opacity: 0.5;
}
}
.entry-btn-text {
font-size: 28rpx;
.pill-action-text {
font-size: 24rpx;
font-weight: 600;
white-space: nowrap;
position: relative;
z-index: 1;
line-height: 1;
}
.login-btn,
.trial-btn,
.book-btn {
.action-login {
background: $primary-color;
.pill-action-text { color: #1a1a2e; }
}
.renew-btn {
background: #666;
.action-trial {
background: rgba(255, 215, 0, 0.2);
.pill-action-text { color: #ffd700; }
}
.login-btn .entry-btn-text,
.trial-btn .entry-btn-text,
.book-btn .entry-btn-text,
.renew-btn .entry-btn-text {
color: #1a1a2e;
.action-book {
background: #1a1a2e;
.pill-action-text { color: #fff; }
}
/* Corner badge */
.card-badge {
position: absolute;
top: 0;
right: 0;
padding: 8rpx 20rpx;
font-size: 20rpx;
font-weight: 600;
border-radius: 0 16rpx 0 16rpx;
.action-renew {
background: #e0e0e0;
.pill-action-text { color: #555; }
}
.trial-badge {
background: linear-gradient(135deg, #ffd700, #ffaa00);
color: #1a1a2e;
}
.low-badge {
background: #e74c3c;
color: #ffffff;
}
/* Renew tip bar */
.renew-tip {
display: flex;
align-items: center;
gap: 12rpx;
margin-top: 16rpx;
padding: 20rpx 24rpx;
background: #fff8f0;
border-radius: 12rpx;
border: 1rpx solid rgba(240, 180, 100, 0.3);
}
.renew-tip-icon {
width: 36rpx;
height: 36rpx;
/* ── Renew strip (running low) ── */
.renew-strip {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
gap: 8rpx;
margin-top: 12rpx;
padding: 14rpx 24rpx;
background: linear-gradient(135deg, #FF6B35, #FF8E53);
border-radius: 24rpx;
}
.renew-tip-text {
flex: 1;
font-size: 24rpx;
color: #a0622a;
line-height: 1.4;
.renew-strip-text {
font-size: 22rpx;
font-weight: 500;
color: #fff;
letter-spacing: 0.5rpx;
}
.renew-tip-action {
font-size: 24rpx;
color: $primary-dark;
font-weight: 600;
flex-shrink: 0;
.renew-strip-arrow {
font-size: 28rpx;
font-weight: 700;
color: rgba(255, 255, 255, 0.8);
line-height: 1;
}
</style>

View File

@@ -1,64 +1,94 @@
<template>
<view class="slot-card" :class="{ 'slot-card--booked': timeSlot.isBookedByMe }">
<!-- Booked accent bar -->
<view v-if="timeSlot.isBookedByMe" class="booked-bar" />
<view class="slot-card-wrapper" :class="[`status-${statusClass}`]" @tap="emit('cardTap', timeSlot)">
<!-- Ticket background image -->
<image
class="ticket-bg"
src="/static/courseBg.png"
mode="aspectFill"
/>
<view class="slot-main">
<!-- Left: Time column -->
<view class="slot-time-col">
<text class="slot-start">{{ timeSlot.startTime.slice(0, 5) }}</text>
<view class="time-divider" />
<text class="slot-end">{{ timeSlot.endTime.slice(0, 5) }}</text>
</view>
<!-- Center: Info -->
<view class="slot-info">
<view class="slot-title-row">
<text class="slot-title">普拉提私教</text>
<text class="slot-duration">{{ durationMin }}分钟</text>
<!-- Card content overlay -->
<view class="ticket-content">
<!-- Top section: Time row (like flight ticket) -->
<view class="ticket-top">
<!-- Left: Start time -->
<view class="time-block">
<text class="time-main">{{ startTimeDisplay }}</text>
<text class="time-label">{{ timeSlot.date }}</text>
</view>
<view class="slot-meta">
<view class="slot-capacity" :class="capacityClass">
<text class="capacity-dot" />
<!-- Center: Duration + icon -->
<view class="duration-block">
<view class="duration-line">
<view class="line-dot" />
<view class="line-dash" />
<view class="duration-icon">
<text class="icon-text"></text>
</view>
<view class="line-dash" />
<view class="line-dot" />
</view>
<text class="duration-text">{{ durationMin }}分钟</text>
</view>
<!-- Right: End time -->
<view class="time-block time-block--right">
<text class="time-main">{{ endTimeDisplay }}</text>
<view class="capacity-tag" :class="capacityClass">
<text class="capacity-text">{{ capacityLabel }}</text>
</view>
</view>
</view>
<!-- Right: Action -->
<view class="slot-action">
<!-- OPEN + not booked -->
<template v-if="timeSlot.status === TimeSlotStatus.OPEN && !timeSlot.isBookedByMe">
<view class="btn btn-book" @tap.stop="emit('book', timeSlot)">
<text class="btn-text">预约</text>
</view>
</template>
<!-- Dashed tear-off line -->
<view class="tear-line" />
<!-- OPEN + booked by me -->
<template v-else-if="timeSlot.status === TimeSlotStatus.OPEN && timeSlot.isBookedByMe">
<view class="booked-badge-col">
<view class="badge-booked">
<text class="badge-text">已预约</text>
<!-- Bottom section: Course name + Action -->
<view class="ticket-bottom">
<view class="course-info">
<text class="course-name">普拉提私教</text>
</view>
<!-- Action area -->
<view class="action-area">
<!-- Expired -->
<template v-if="isPast && !timeSlot.isBookedByMe">
<view class="action-badge badge-expired">
<text>已过期</text>
</view>
<view class="btn-cancel" @tap.stop="emit('cancel', timeSlot)">
<text class="btn-cancel-text">取消预约</text>
</template>
<!-- OPEN + not booked -->
<template v-else-if="timeSlot.status === TimeSlotStatus.OPEN && !timeSlot.isBookedByMe">
<view class="action-btn btn-book" @tap.stop="emit('book', timeSlot)">
<text>预约</text>
</view>
</view>
</template>
</template>
<!-- FULL -->
<template v-else-if="timeSlot.status === TimeSlotStatus.FULL">
<view class="btn btn-full">
<text class="btn-text">已约满</text>
</view>
</template>
<!-- OPEN + booked by me -->
<template v-else-if="timeSlot.status === TimeSlotStatus.OPEN && timeSlot.isBookedByMe">
<view class="action-badge badge-booked">
<text>{{ myBookingLabel }}</text>
</view>
<view class="cancel-link" @tap.stop="emit('cancel', timeSlot)">
<text>取消</text>
</view>
</template>
<!-- CLOSED -->
<template v-else>
<view class="btn btn-closed">
<text class="btn-text">关闭</text>
</view>
</template>
<!-- FULL -->
<template v-else-if="timeSlot.status === TimeSlotStatus.FULL">
<view class="action-badge badge-full">
<text>约满</text>
</view>
</template>
<!-- CLOSED -->
<template v-else>
<view class="action-badge badge-closed">
<text>已关闭</text>
</view>
</template>
</view>
</view>
</view>
</view>
@@ -66,8 +96,9 @@
<script setup lang="ts">
import type { TimeSlotWithBookingStatus } from '@mp-pilates/shared'
import { TimeSlotStatus } from '@mp-pilates/shared'
import { BookingStatus, TimeSlotStatus } from '@mp-pilates/shared'
import { computed } from 'vue'
import { isSlotPast } from '../utils/format'
interface Props {
timeSlot: TimeSlotWithBookingStatus
@@ -77,20 +108,30 @@ const props = defineProps<Props>()
const emit = defineEmits<{
book: [timeSlot: TimeSlotWithBookingStatus]
cancel: [timeSlot: TimeSlotWithBookingStatus]
cardTap: [timeSlot: TimeSlotWithBookingStatus]
}>()
const startTimeDisplay = computed(() => props.timeSlot.startTime.slice(0, 5))
const endTimeDisplay = computed(() => props.timeSlot.endTime.slice(0, 5))
const durationMin = computed(() => {
const [sh, sm] = props.timeSlot.startTime.split(':').map(Number)
const [eh, em] = props.timeSlot.endTime.split(':').map(Number)
return (eh * 60 + em) - (sh * 60 + sm)
})
const myBookingLabel = computed(() => (
props.timeSlot.myBookingStatus === BookingStatus.PENDING_CONFIRMATION
? '已预约待确认'
: '已预约'
))
const capacityLabel = computed(() => {
const { bookedCount, capacity, status } = props.timeSlot
if (status === TimeSlotStatus.CLOSED) return '已关闭'
if (status === TimeSlotStatus.FULL) return '已约满'
const remaining = capacity - bookedCount
return `剩余 ${remaining} 个名额`
return `剩余${remaining}`
})
const capacityClass = computed(() => {
@@ -100,223 +141,289 @@ const capacityClass = computed(() => {
if (bookedCount >= capacity * 0.8) return 'cap-almost'
return 'cap-open'
})
const statusClass = computed(() => {
if (isPast.value && !props.timeSlot.isBookedByMe) return 'expired'
if (props.timeSlot.isBookedByMe) return 'booked'
if (props.timeSlot.status === TimeSlotStatus.FULL) return 'full'
if (props.timeSlot.status === TimeSlotStatus.CLOSED) return 'closed'
return 'open'
})
const isPast = computed(() => isSlotPast(props.timeSlot.date, props.timeSlot.startTime))
</script>
<style lang="scss" scoped>
.slot-card {
background: #fff;
border-radius: 24rpx;
overflow: hidden;
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.05);
/* ─── Wrapper ─── */
.slot-card-wrapper {
position: relative;
transition: transform 0.15s, box-shadow 0.15s;
margin: 0 24rpx 20rpx;
min-height: 220rpx;
transition: all 0.2s ease;
filter: drop-shadow(0 16rpx 28rpx rgba(120, 91, 79, 0.08));
&:active {
transform: scale(0.985);
transform: scale(0.98);
}
&--booked {
background: #f0f7fb;
box-shadow: 0 4rpx 24rpx rgba($primary-dark, 0.12);
/* Status-based opacity */
&.status-expired {
opacity: 0.55;
}
&.status-full,
&.status-closed {
opacity: 0.75;
}
}
.booked-bar {
/* ─── Ticket background image ─── */
.ticket-bg {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 8rpx;
background: linear-gradient(180deg, $primary-color, $primary-dark);
border-radius: 24rpx 0 0 24rpx;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.slot-main {
display: flex;
flex-direction: row;
align-items: center;
padding: 32rpx 28rpx 32rpx 36rpx;
gap: 24rpx;
}
/* ── Time column ─── */
.slot-time-col {
/* ─── Content overlay ─── */
.ticket-content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
min-width: 80rpx;
flex-shrink: 0;
padding: 28rpx 40rpx 24rpx;
}
.slot-start {
font-size: 34rpx;
font-weight: 700;
color: #1a1a1a;
line-height: 1.2;
/* ═══ Top section: Time row ═══ */
.ticket-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.time-divider {
width: 2rpx;
height: 16rpx;
background: #e0dcd6;
margin: 6rpx 0;
border-radius: 1rpx;
}
.slot-end {
font-size: 24rpx;
font-weight: 500;
color: #999;
line-height: 1.2;
}
/* ── Info ─── */
.slot-info {
flex: 1;
.time-block {
display: flex;
flex-direction: column;
gap: 10rpx;
min-width: 0;
align-items: flex-start;
min-width: 100rpx;
&--right {
align-items: flex-end;
}
}
.slot-title-row {
display: flex;
flex-direction: row;
align-items: baseline;
gap: 12rpx;
.time-main {
font-size: 40rpx;
font-weight: 800;
color: #3a2e2a;
line-height: 1;
letter-spacing: 1rpx;
}
.slot-title {
font-size: 30rpx;
font-weight: 600;
color: #1a1a1a;
}
.slot-duration {
.time-label {
margin-top: 8rpx;
font-size: 22rpx;
color: #bbb;
font-weight: 400;
color: #a18a82;
font-weight: 500;
}
.slot-meta {
/* Duration center block */
.duration-block {
display: flex;
flex-direction: row;
flex-direction: column;
align-items: center;
gap: 12rpx;
flex: 1;
padding: 0 16rpx;
}
.slot-capacity {
display: inline-flex;
.duration-line {
display: flex;
align-items: center;
gap: 8rpx;
.capacity-dot {
width: 10rpx;
height: 10rpx;
border-radius: 50%;
}
.capacity-text {
font-size: 22rpx;
font-weight: 500;
}
&.cap-open {
.capacity-dot { background: #4caf50; }
.capacity-text { color: #4caf50; }
}
&.cap-almost {
.capacity-dot { background: #f59e0b; }
.capacity-text { color: #f59e0b; }
}
&.cap-full {
.capacity-dot { background: #ef4444; }
.capacity-text { color: #ef4444; }
}
&.cap-closed {
.capacity-dot { background: #ccc; }
.capacity-text { color: #999; }
}
width: 100%;
margin-top: 8rpx;
}
/* ── Action ─── */
.slot-action {
.line-dot {
width: 10rpx;
height: 10rpx;
border-radius: 50%;
background: #ccb7ae;
flex-shrink: 0;
}
.btn {
min-width: 140rpx;
height: 72rpx;
border-radius: 36rpx;
.line-dash {
flex: 1;
height: 2rpx;
background: repeating-linear-gradient(
to right,
#dccbc2 0,
#dccbc2 8rpx,
transparent 8rpx,
transparent 16rpx
);
}
.duration-icon {
flex-shrink: 0;
width: 48rpx;
height: 48rpx;
border-radius: 50%;
background: rgba(185, 143, 125, 0.12);
display: flex;
align-items: center;
justify-content: center;
padding: 0 32rpx;
margin: 0 8rpx;
}
.btn-text {
font-size: 26rpx;
font-weight: 600;
}
.icon-text {
font-size: 22rpx;
}
&.btn-book {
background: linear-gradient(135deg, $primary-color, $primary-dark);
box-shadow: 0 4rpx 16rpx rgba($primary-dark, 0.3);
.duration-text {
margin-top: 6rpx;
font-size: 22rpx;
color: #a18a82;
font-weight: 500;
}
.btn-text { color: #fff; }
/* Capacity tag */
.capacity-tag {
margin-top: 8rpx;
padding: 4rpx 12rpx;
border-radius: 6rpx;
font-size: 20rpx;
&:active {
opacity: 0.85;
&.cap-open {
background: rgba(101, 163, 126, 0.12);
.capacity-text {
color: #5d9472;
}
}
&.btn-full {
background: #fef0f0;
&.cap-almost {
background: rgba(214, 161, 92, 0.14);
.btn-text { color: #ef4444; }
.capacity-text {
color: #b98543;
}
}
&.btn-closed {
background: #f5f5f5;
&.cap-full {
background: rgba(216, 91, 87, 0.12);
.btn-text { color: #bbb; }
.capacity-text {
color: #c96763;
}
}
&.cap-closed {
background: rgba(111, 96, 91, 0.08);
.capacity-text {
color: #9d8f89;
}
}
}
.booked-badge-col {
.capacity-text {
font-size: 20rpx;
font-weight: 600;
}
/* ═══ Tear-off dashed line ═══ */
.tear-line {
margin: 20rpx -40rpx 16rpx;
height: 2rpx;
background: repeating-linear-gradient(
to right,
#e0dcd6 0,
#e0dcd6 10rpx,
transparent 10rpx,
transparent 20rpx
);
opacity: 0.6;
}
/* ═══ Bottom section ═══ */
.ticket-bottom {
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
justify-content: space-between;
}
.badge-booked {
height: 52rpx;
padding: 0 24rpx;
background: linear-gradient(135deg, $primary-selected-bg, $primary-border);
border-radius: 26rpx;
.course-info {
flex: 1;
}
.course-name {
font-size: 30rpx;
font-weight: 700;
color: #3a2e2a;
letter-spacing: 1rpx;
}
/* ─── Action area ─── */
.action-area {
display: flex;
align-items: center;
gap: 12rpx;
flex-shrink: 0;
}
.action-btn,
.action-badge {
padding: 10rpx 24rpx;
border-radius: 20rpx;
font-size: 26rpx;
font-weight: 600;
white-space: nowrap;
display: flex;
align-items: center;
justify-content: center;
}
.badge-text {
font-size: 24rpx;
color: $primary-dark;
font-weight: 600;
.btn-book {
background: linear-gradient(135deg, #d5b9ab 0%, #b98f7d 100%);
color: #fffaf7;
box-shadow: 0 8rpx 18rpx rgba(143, 103, 89, 0.24);
min-width: 120rpx;
height: 60rpx;
transition: all 0.15s;
&:active {
opacity: 0.85;
transform: scale(0.96);
}
}
.btn-cancel {
padding: 4rpx 8rpx;
display: flex;
align-items: center;
.badge-booked {
background: linear-gradient(135deg, rgba(247, 240, 235, 0.96), rgba(236, 225, 217, 0.98));
color: #8f6759;
}
.btn-cancel-text {
font-size: 22rpx;
color: #ef4444;
font-weight: 400;
}
.badge-expired {
background: rgba(111, 96, 91, 0.08);
color: #9d8f89;
}
.badge-full {
background: rgba(216, 91, 87, 0.12);
color: #c96763;
}
.badge-closed {
background: rgba(111, 96, 91, 0.08);
color: #b5a8a1;
}
.cancel-link {
font-size: 22rpx;
color: #c96763;
font-weight: 500;
text-decoration: underline;
text-decoration-color: rgba(201, 103, 99, 0.28);
}
</style>

View File

@@ -1,61 +1,78 @@
<template>
<view class="studio-info">
<!-- Horizontal photo strip -->
<scroll-view v-if="studioInfo?.photos?.length" scroll-x class="photo-strip" :show-scrollbar="false">
<view class="photo-strip-inner">
<image v-for="(photo, idx) in studioInfo.photos" :key="idx" class="strip-photo" :src="photo" mode="aspectFill"
@tap="previewPhoto(idx)" />
</view>
</scroll-view>
<!-- Address + Phone row -->
<!-- Address + Chat row -->
<view class="location-row">
<view class="location-left" @tap="handleAddressTap">
<text class="location-icon">📍</text>
<text class="location-text">
{{ studioInfo?.address || '深圳市宝安区西乡街道财富港 D 座 1203D' }}
</text>
</view>
<view class="phone-btn" @tap="handlePhoneTap">
<text class="phone-icon">📞</text>
<view class="location-icon" />
<view class="location-content">
<text class="location-label">场馆地址</text>
<text class="location-text">
{{ studioInfo?.address || '深圳市宝安区西乡街道财富港 D 座 1203D' }}
</text>
</view>
</view>
<button class="chat-btn" open-type="contact">
<view class="chat-icon" />
</button>
</view>
<!-- Horizontal gallery -->
<view class="gallery-block">
<scroll-view scroll-x class="gallery-scroll" :show-scrollbar="false" enhanced>
<view class="gallery-track">
<view
v-for="(photo, idx) in galleryPhotos"
:key="photo"
class="gallery-item"
@tap="previewPhoto(idx)"
>
<image class="gallery-image" :src="photo" mode="aspectFill" />
<view class="gallery-overlay" />
</view>
</view>
</scroll-view>
</view>
</view>
</template>
<script setup lang="ts">
import type { StudioConfig } from '@mp-pilates/shared'
import { computed } from 'vue'
import {
DEFAULT_STUDIO_GALLERY_PHOTOS,
type StudioConfig,
} from '@mp-pilates/shared'
const props = defineProps<{
studioInfo: StudioConfig | null
}>()
const galleryPhotos = computed(() => {
const photos = props.studioInfo?.photos?.filter(Boolean) ?? []
return photos.length ? photos : [...DEFAULT_STUDIO_GALLERY_PHOTOS]
})
function previewPhoto(index: number) {
if (!props.studioInfo?.photos?.length) return
uni.previewImage({
current: index,
urls: props.studioInfo.photos,
urls: galleryPhotos.value,
})
}
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,43 +85,15 @@ 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>
.studio-info {
margin: 16rpx 24rpx 0;
background: #ffffff;
}
/* ── Photo strip ── */
.photo-strip {
width: 100%;
padding: 24rpx 0;
}
.photo-strip-inner {
display: flex;
flex-direction: row;
gap: 16rpx;
padding: 0 24rpx;
width: max-content;
}
.strip-photo {
width: 240rpx;
height: 160rpx;
border-radius: 12rpx;
flex-shrink: 0;
border-radius: 20rpx;
overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
}
/* ── Location row ── */
@@ -112,7 +101,7 @@ function handlePhoneTap() {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 32rpx 28rpx;
padding: 28rpx 32rpx 24rpx;
gap: 24rpx;
}
@@ -124,31 +113,154 @@ function handlePhoneTap() {
min-width: 0;
}
.location-icon {
font-size: 28rpx;
flex-shrink: 0;
.location-content {
flex: 1;
min-width: 0;
}
.location-label {
display: block;
font-size: 22rpx;
color: #b39a92;
letter-spacing: 2rpx;
margin-bottom: 6rpx;
}
.location-text {
font-size: 26rpx;
color: #666;
line-height: 1.5;
word-break: break-all;
color: #5f5955;
line-height: 1.6;
word-break: break-word;
}
.phone-btn {
/* ── Gallery ── */
.gallery-block {
padding: 6rpx 0 28rpx;
}
.gallery-scroll {
width: 100%;
}
.gallery-track {
display: flex;
gap: 14rpx;
padding: 0 32rpx;
width: max-content;
}
.gallery-item {
position: relative;
width: 192rpx;
height: 108rpx;
border-radius: 14rpx;
overflow: hidden;
flex-shrink: 0;
background: linear-gradient(135deg, #eadfd8 0%, #d5c0b4 100%);
box-shadow: 0 8rpx 18rpx rgba(124, 95, 82, 0.1);
}
.gallery-image {
width: 100%;
height: 100%;
}
.gallery-overlay {
position: absolute;
inset: 0;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.1) 0%, rgba(38, 28, 24, 0.2) 100%),
linear-gradient(135deg, rgba(255, 255, 255, 0.08) 0%, transparent 48%);
}
/* ── 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

@@ -1,10 +1,10 @@
<template>
<view class="time-period-filter">
<view class="time-period-filter" :class="`time-period-filter--${variant}`">
<view
v-for="tab in tabs"
:key="tab.key ?? 'all'"
class="tab-item"
:class="{ active: modelValue === tab.key }"
:class="[`tab-item--${variant}`, { active: modelValue === tab.key }]"
@tap="handleChange(tab.key)"
>
<text class="tab-label">{{ tab.label }}</text>
@@ -25,14 +25,17 @@ interface Tab {
interface Props {
modelValue: PeriodKey
variant?: 'default' | 'booking'
}
defineProps<Props>()
const props = defineProps<Props>()
const emit = defineEmits<{
change: [period: PeriodKey]
'update:modelValue': [period: PeriodKey]
}>()
const variant = computed(() => props.variant ?? 'default')
const tabs = computed<Tab[]>(() => [
{ key: null, label: '全部' },
...Object.entries(TIME_PERIODS).map(([key, val]) => ({
@@ -55,6 +58,11 @@ function handleChange(key: PeriodKey) {
padding: 0 24rpx;
border-bottom: 1rpx solid $primary-border;
&.time-period-filter--booking {
background: rgba(252, 250, 248, 0.96);
border-bottom-color: rgba(192, 154, 137, 0.12);
}
.tab-item {
flex: 1;
display: flex;
@@ -87,6 +95,26 @@ function handleChange(key: PeriodKey) {
border-radius: 2rpx;
}
}
&.tab-item--booking {
.tab-label {
color: #9d8b83;
}
&.active {
.tab-label {
color: #8f6759;
}
&::after {
width: 48rpx;
height: 5rpx;
background: linear-gradient(90deg, #c8a899, #a87d6c);
border-radius: 999rpx;
box-shadow: 0 4rpx 10rpx rgba(168, 125, 108, 0.18);
}
}
}
}
}
</style>

View File

@@ -9,6 +9,7 @@
v-for="booking in displayedBookings"
:key="booking.id"
class="booking-card"
@tap="goToBookingDetail(booking.id)"
>
<!-- Date column -->
<view class="date-col">
@@ -72,6 +73,7 @@ function formatTime(timeStr: string): string {
function statusLabel(status: BookingStatus): string {
const map: Record<BookingStatus, string> = {
[BookingStatus.PENDING_CONFIRMATION]: '待确认',
[BookingStatus.CONFIRMED]: '已确认',
[BookingStatus.CANCELLED]: '已取消',
[BookingStatus.COMPLETED]: '已完成',
@@ -81,6 +83,7 @@ function statusLabel(status: BookingStatus): string {
}
function statusDotClass(status: BookingStatus): string {
if (status === BookingStatus.PENDING_CONFIRMATION) return 'dot--pending'
if (status === BookingStatus.CONFIRMED) return 'dot--confirmed'
if (status === BookingStatus.COMPLETED) return 'dot--completed'
if (status === BookingStatus.CANCELLED) return 'dot--cancelled'
@@ -88,6 +91,7 @@ function statusDotClass(status: BookingStatus): string {
}
function statusTextClass(status: BookingStatus): string {
if (status === BookingStatus.PENDING_CONFIRMATION) return 'text--pending'
if (status === BookingStatus.CONFIRMED) return 'text--confirmed'
if (status === BookingStatus.COMPLETED) return 'text--completed'
if (status === BookingStatus.CANCELLED) return 'text--cancelled'
@@ -97,6 +101,10 @@ function statusTextClass(status: BookingStatus): string {
function goToBookings() {
uni.navigateTo({ url: '/pages/profile/bookings' })
}
function goToBookingDetail(id: string) {
uni.navigateTo({ url: `/pages/booking/detail?id=${id}` })
}
</script>
<style lang="scss" scoped>
@@ -207,6 +215,7 @@ function goToBookings() {
border-radius: 50%;
}
.dot--pending { background: #f39c12; }
.dot--confirmed { background: #27ae60; }
.dot--completed { background: #3498db; }
.dot--cancelled { background: #e74c3c; }
@@ -217,6 +226,7 @@ function goToBookings() {
color: #999;
}
.text--pending { color: #f39c12; }
.text--confirmed { color: #27ae60; }
.text--completed { color: #3498db; }
.text--cancelled { color: #e74c3c; }

View File

@@ -25,14 +25,16 @@
mode="aspectFill"
@error="onAvatarError"
/>
<!-- VIP badge hidden for now -->
<!-- <view class="user-card__vip-badge" v-if="vipLevel">
<text class="user-card__vip-text">{{ vipLevel }}</text>
</view> -->
</view>
<view class="user-card__info">
<view class="user-card__name-row">
<text class="user-card__nickname">{{ user!.nickname }}</text>
<view v-if="hasMembership" class="user-card__member-badge">
<view class="user-card__member-icon">
<text class="user-card__member-letter">C</text>
</view>
<text class="user-card__member-label">CLUB</text>
</view>
</view>
<text v-if="maskedPhone" class="user-card__phone">{{ maskedPhone }}</text>
</view>
@@ -75,7 +77,7 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import type { UserProfileResponse, UserStatsResponse, MembershipWithCardType } from '@mp-pilates/shared'
import { MembershipStatus } from '@mp-pilates/shared'
import { CardTypeCategory, MembershipStatus } from '@mp-pilates/shared'
const props = defineProps<{
loggedIn: boolean
@@ -117,24 +119,25 @@ const maskedPhone = computed(() => {
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
})
// Derive VIP level from active memberships count
const activeMemberships = computed(() =>
props.memberships?.filter((m) => m.status === MembershipStatus.ACTIVE) ?? [],
)
const vipLevel = computed(() => {
const count = activeMemberships.value.length
if (count >= 3) return 'VIP3'
if (count >= 2) return 'VIP2'
if (count >= 1) return 'VIP1'
return null
})
const activeMembershipCount = computed(
() => props.user?.activeMembershipCount ?? activeMemberships.value.length,
)
const hasMembership = computed(() => activeMembershipCount.value > 0)
function toSafeCount(value: number | null | undefined): number {
return typeof value === 'number' && Number.isFinite(value) ? value : 0
}
// Sum remaining sessions from all active time-based memberships
const remainingSessions = computed(() =>
activeMemberships.value
.filter((m) => m.cardType.type === 'TIMES')
.reduce((sum, m) => sum + m.remainingCount, 0),
.filter((m) => m.cardType.type === CardTypeCategory.TIMES)
.reduce((sum, m) => sum + toSafeCount(m.remainingTimes), 0),
)
function onAvatarError() {
@@ -220,24 +223,6 @@ function handleLogin() {
border: 4rpx solid rgba(255, 255, 255, 0.4);
}
&__vip-badge {
position: absolute;
bottom: -6rpx;
left: 50%;
transform: translateX(-50%);
background: linear-gradient(135deg, #fbbf24, #f59e0b);
border-radius: 20rpx;
padding: 2rpx 12rpx;
border: 2rpx solid #ffffff;
}
&__vip-text {
font-size: 18rpx;
font-weight: 700;
color: #7c2d12;
line-height: 1;
}
&__info {
flex: 1;
display: flex;
@@ -257,6 +242,59 @@ function handleLogin() {
color: #ffffff;
}
&__member-badge {
display: inline-flex;
align-items: center;
gap: 10rpx;
padding: 6rpx 14rpx 6rpx 8rpx;
border-radius: 999rpx;
background: linear-gradient(135deg, rgba(255, 249, 245, 0.98), rgba(242, 229, 221, 0.96));
border: 1rpx solid rgba(192, 154, 137, 0.18);
box-shadow:
inset 0 1rpx 0 rgba(255, 255, 255, 0.9),
0 8rpx 20rpx rgba(143, 103, 89, 0.14);
}
&__member-icon {
width: 34rpx;
height: 34rpx;
border-radius: 50%;
position: relative;
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(circle at 30% 30%, #fffdfb 0%, #f2e2d8 40%, #c79d89 100%);
box-shadow:
inset 0 2rpx 4rpx rgba(255, 255, 255, 0.76),
0 3rpx 8rpx rgba(143, 103, 89, 0.16);
&::after {
content: '';
position: absolute;
inset: 3rpx;
border-radius: 50%;
border: 1.5rpx solid rgba(143, 103, 89, 0.28);
}
}
&__member-letter {
position: relative;
z-index: 1;
font-size: 20rpx;
line-height: 1;
font-weight: 800;
color: #8f6759;
letter-spacing: 1rpx;
}
&__member-label {
font-size: 20rpx;
line-height: 1;
font-weight: 700;
color: #8f6759;
letter-spacing: 2rpx;
}
&__phone {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.75);

View File

@@ -1,12 +1,12 @@
{
"name": "普拉提约课",
"appid": "",
"appid": "wx3e7a133d2305fa2c",
"description": "普拉提工作室约课小程序",
"versionName": "0.1.0",
"versionCode": "100",
"transformPx": false,
"mp-weixin": {
"appid": "",
"appid": "wx3e7a133d2305fa2c",
"setting": {
"urlCheck": false,
"es6": true,

View File

@@ -45,12 +45,30 @@
"navigationStyle": "custom"
}
},
{
"path": "pages/profile/teaching-schedule",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/profile/info",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/profile/invite",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/teacher/detail",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/admin/index",
"style": {
@@ -69,12 +87,6 @@
"navigationStyle": "custom"
}
},
{
"path": "pages/admin/week-template",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/admin/slot-adjust",
"style": {
@@ -104,6 +116,18 @@
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/admin/flash-sales",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/flash-sale/detail",
"style": {
"navigationStyle": "custom"
}
}
],
"globalStyle": {

View File

@@ -188,6 +188,32 @@
auto-height
/>
</view>
<!-- Cover image upload -->
<view class="modal-field modal-field--cover">
<text class="modal-label">封面图</text>
<view class="cover-upload-area">
<view v-if="form.coverUrl" class="cover-preview-wrap">
<image class="cover-preview-img" :src="form.coverUrl" mode="aspectFill" />
<view class="cover-remove-btn" @tap="clearCover">
<text class="cover-remove-icon"></text>
</view>
</view>
<view
v-else
class="cover-upload-btn"
:class="{ 'cover-upload-btn--loading': uploadingCover }"
@tap="uploadCover"
>
<text v-if="uploadingCover" class="cover-upload-hint">上传中...</text>
<template v-else>
<text class="cover-upload-plus"></text>
<text class="cover-upload-hint">上传封面</text>
</template>
</view>
<text class="cover-upload-tip">可选建议 3:2 比例</text>
</view>
</view>
</view>
<!-- Action buttons -->
@@ -215,6 +241,7 @@ import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from '../../stores/admin'
import { formatPrice } from '../../utils/format'
import { uploadStudioAsset } from '../../utils/studio-upload'
import { CardTypeCategory } from '@mp-pilates/shared'
import type { CardType } from '@mp-pilates/shared'
@@ -229,6 +256,7 @@ const cardTypes = ref<CardType[]>([])
const loading = ref(false)
const showModal = ref(false)
const submitting = ref(false)
const uploadingCover = ref(false)
const editTarget = ref<CardType | null>(null)
const typeOptions = [
@@ -246,6 +274,7 @@ const defaultForm = () => ({
durationDaysStr: '90',
sortOrderStr: '0',
description: '',
coverUrl: '',
})
const form = ref(defaultForm())
@@ -282,6 +311,7 @@ function openEdit(ct: CardType) {
durationDaysStr: String(ct.durationDays),
sortOrderStr: String(ct.sortOrder),
description: ct.description ?? '',
coverUrl: ct.coverUrl ?? '',
}
showModal.value = true
}
@@ -349,6 +379,9 @@ async function submitForm() {
if (form.value.description.trim()) {
payload.description = form.value.description.trim()
}
if (form.value.coverUrl) {
payload.coverUrl = form.value.coverUrl
}
submitting.value = true
try {
@@ -431,6 +464,85 @@ function confirmDelete(ct: CardType) {
})
}
// ─── Cover image upload ─────────────────────────────
async function uploadCover() {
if (uploadingCover.value) return
try {
const file = await chooseSingleImage()
if (!file) return
uploadingCover.value = true
const url = await uploadStudioAsset({
adminStore,
filePath: file.path,
fileName: file.name,
assetType: 'card-cover',
})
form.value.coverUrl = url
uni.showToast({ title: '上传成功', icon: 'success' })
} catch (error: unknown) {
const message = error instanceof Error ? error.message : '上传失败'
uni.showToast({ title: message, icon: 'none' })
} finally {
uploadingCover.value = false
}
}
function clearCover() {
form.value.coverUrl = ''
}
interface PickedImage {
readonly path: string
readonly name: string
}
function extractFileName(filePath: string): string {
return filePath.split('/').pop() || `image_${Date.now()}.jpg`
}
function chooseSingleImage(): Promise<PickedImage | null> {
return new Promise((resolve, reject) => {
uni.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: (result) => {
const tempFilePaths = Array.isArray(result.tempFilePaths)
? result.tempFilePaths
: typeof result.tempFilePaths === 'string'
? [result.tempFilePaths]
: []
const path = tempFilePaths[0]
if (!path) {
resolve(null)
return
}
const tempFiles = Array.isArray(result.tempFiles)
? result.tempFiles
: result.tempFiles
? [result.tempFiles]
: []
const file = tempFiles[0] as { path?: string; tempFilePath?: string; name?: string } | undefined
resolve({
path,
name: file?.name || extractFileName(file?.path || file?.tempFilePath || path),
})
},
fail: (error) => {
if ((error.errMsg || '').includes('cancel')) {
resolve(null)
return
}
reject(new Error(error.errMsg || '选择图片失败'))
},
})
})
}
// ─── Helpers ─────────────────────────────────────────
function typeLabel(ct: CardType): string {
@@ -522,16 +634,16 @@ onMounted(fetchCardTypes)
justify-content: space-between;
}
.header--times { background: linear-gradient(90deg, #1a1a2e, #2d2d5e); }
.header--duration { background: linear-gradient(90deg, #6c3483, #9b59b6); }
.header--trial { background: linear-gradient(90deg, #5a7a8a, $primary-dark); }
.header--times { background: linear-gradient(90deg, #E8D5C4, #D4BFA8); }
.header--duration { background: linear-gradient(90deg, #D8C8DC, #C4AECB); }
.header--trial { background: linear-gradient(90deg, #C8D8D2, #A9C4BC); }
.ct-type-label { font-size: 22rpx; font-weight: 600; color: #ffffff; letter-spacing: 2rpx; }
.ct-type-label { font-size: 22rpx; font-weight: 600; color: $brand-color; letter-spacing: 2rpx; }
.ct-status-tag { border-radius: 20rpx; padding: 4rpx 16rpx; }
.tag--on { background: rgba(255,255,255,0.2); }
.tag--off { background: rgba(0,0,0,0.2); }
.ct-status-text { font-size: 20rpx; color: #ffffff; }
.tag--on { background: rgba(74, 64, 53, 0.1); }
.tag--off { background: rgba(74, 64, 53, 0.08); }
.ct-status-text { font-size: 20rpx; color: $brand-color; }
.ct-body { padding: 24rpx; }
@@ -721,4 +833,82 @@ onMounted(fetchCardTypes)
}
.modal-confirm-text { font-size: 28rpx; font-weight: 700; color: $primary-dark; }
/* ── Cover upload ───────────────────────── */
.modal-field--cover {
flex-direction: column;
align-items: flex-start;
gap: 16rpx;
border-bottom: none;
}
.cover-upload-area {
width: 100%;
display: flex;
flex-direction: column;
gap: 12rpx;
}
.cover-preview-wrap {
position: relative;
width: 300rpx;
height: 200rpx;
border-radius: 12rpx;
overflow: hidden;
}
.cover-preview-img {
width: 100%;
height: 100%;
}
.cover-remove-btn {
position: absolute;
top: 8rpx;
right: 8rpx;
width: 44rpx;
height: 44rpx;
background: rgba(0, 0, 0, 0.5);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.cover-remove-icon {
font-size: 20rpx;
color: #ffffff;
}
.cover-upload-btn {
width: 300rpx;
height: 200rpx;
border: 2rpx dashed #ddd;
border-radius: 12rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8rpx;
background: #fafafa;
&:active { background: #f0f0f0; }
&--loading { opacity: 0.6; pointer-events: none; }
}
.cover-upload-plus {
font-size: 48rpx;
color: #bbb;
line-height: 1;
}
.cover-upload-hint {
font-size: 22rpx;
color: #999;
}
.cover-upload-tip {
font-size: 20rpx;
color: #bbb;
}
</style>

View File

@@ -0,0 +1,863 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="秒杀管理" show-back />
<!-- Toolbar -->
<view class="toolbar">
<text class="toolbar-hint"> {{ total }} 个秒杀活动</text>
<view class="add-btn" @tap="openAdd">
<text class="add-btn-text"> 新建秒杀</text>
</view>
</view>
<!-- Loading skeleton -->
<view v-if="pageLoading" class="skeleton-list">
<view v-for="i in 3" :key="i" class="skeleton-item" />
</view>
<!-- Empty -->
<view v-else-if="!items.length" class="empty-state">
<text class="empty-icon"></text>
<text class="empty-text">暂无秒杀活动点击右上角新建</text>
</view>
<!-- Flash sale list -->
<view v-else class="fs-list">
<view
v-for="item in items"
:key="item.id"
class="fs-card"
>
<!-- Header band -->
<view class="fs-header" :class="headerStatusClass(item)">
<view class="fs-header-left">
<text class="fs-title">{{ item.title }}</text>
</view>
<view class="fs-status-tag" :class="phaseTagClass(item.phase)">
<text class="fs-status-text">{{ phaseLabel(item.phase) }}</text>
</view>
</view>
<!-- Body -->
<view class="fs-body">
<view class="fs-info-row">
<text class="fs-card-type">关联卡种: {{ item.cardType.name }}</text>
</view>
<view class="fs-price-row">
<view class="fs-price-block">
<text class="fs-price-label">秒杀价</text>
<text class="fs-price-value flash">¥{{ formatPrice(item.flashPrice) }}</text>
</view>
<view class="fs-price-block">
<text class="fs-price-label">原价</text>
<text class="fs-price-value original">¥{{ formatPrice(item.originalPrice) }}</text>
</view>
<view class="fs-price-block">
<text class="fs-price-label">库存</text>
<text class="fs-price-value">{{ item.soldCount }}/{{ item.totalStock }}</text>
</view>
</view>
<!-- Stock progress bar -->
<view class="fs-stock-bar">
<view
class="fs-stock-fill"
:style="{ width: stockPercent(item) }"
/>
</view>
<view class="fs-time-row">
<text class="fs-time">{{ formatDateTime(item.startTime) }} {{ formatDateTime(item.endTime) }}</text>
</view>
</view>
<!-- Actions -->
<view class="fs-actions">
<view class="fs-action-btn edit-btn" @tap.stop="openEdit(item)">
<text class="fs-action-text">编辑</text>
</view>
<view
v-if="item.status === 'DRAFT'"
class="fs-action-btn activate-btn"
@tap.stop="confirmActivate(item)"
>
<text class="fs-action-text">上线</text>
</view>
<view
v-else-if="item.status === 'ACTIVE'"
class="fs-action-btn end-btn"
@tap.stop="confirmEnd(item)"
>
<text class="fs-action-text">结束</text>
</view>
<view
v-if="item.soldCount === 0"
class="fs-action-btn delete-btn"
@tap.stop="confirmDelete(item)"
>
<text class="fs-action-text">删除</text>
</view>
</view>
</view>
</view>
<!-- Add / Edit modal -->
<view v-if="showModal" class="modal-mask" @tap.stop="closeModal">
<view class="modal-container" @tap.stop>
<scroll-view scroll-y class="modal-scroll">
<!-- Header -->
<view class="modal-header">
<text class="modal-title">{{ editTarget ? '编辑秒杀' : '新建秒杀' }}</text>
<view class="modal-close" @tap="closeModal">
<text class="modal-close-icon"></text>
</view>
</view>
<!-- Form fields -->
<view class="modal-body">
<!-- Card type picker -->
<view class="modal-field">
<text class="modal-label">关联卡种</text>
<picker
mode="selector"
:range="cardTypeOptions"
range-key="label"
:value="form.cardTypeIdx"
@change="onCardTypeChange"
:disabled="!!editTarget"
>
<view class="picker-display">
<text class="picker-text">{{ cardTypeOptions[form.cardTypeIdx]?.label || '请选择' }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<view class="modal-field">
<text class="modal-label">活动标题</text>
<input
class="modal-input"
v-model="form.title"
placeholder="如:新春限时秒杀"
placeholder-style="color:#bbb"
/>
</view>
<view class="modal-field">
<text class="modal-label">原价</text>
<input
class="modal-input"
type="digit"
v-model="form.originalPriceStr"
placeholder="展示划线价"
placeholder-style="color:#bbb"
/>
</view>
<view class="modal-field">
<text class="modal-label">秒杀价</text>
<input
class="modal-input"
type="digit"
v-model="form.flashPriceStr"
placeholder="实际支付价格"
placeholder-style="color:#bbb"
/>
</view>
<view class="modal-field">
<text class="modal-label">库存数量</text>
<input
class="modal-input"
type="number"
v-model="form.totalStockStr"
placeholder="秒杀总量"
placeholder-style="color:#bbb"
/>
</view>
<view class="modal-field">
<text class="modal-label">开始时间</text>
<view class="datetime-picker-group">
<picker
mode="date"
:value="form.startDate"
@change="onStartDateChange"
>
<text class="datetime-text">{{ form.startDate || '选择日期' }}</text>
</picker>
<picker
mode="time"
:value="form.startTimeStr"
@change="onStartTimeChange"
>
<text class="datetime-text">{{ form.startTimeStr || '选择时间' }}</text>
</picker>
</view>
</view>
<view class="modal-field">
<text class="modal-label">结束时间</text>
<view class="datetime-picker-group">
<picker
mode="date"
:value="form.endDate"
@change="onEndDateChange"
>
<text class="datetime-text">{{ form.endDate || '选择日期' }}</text>
</picker>
<picker
mode="time"
:value="form.endTimeStr"
@change="onEndTimeChange"
>
<text class="datetime-text">{{ form.endTimeStr || '选择时间' }}</text>
</picker>
</view>
</view>
<view class="modal-field">
<text class="modal-label">排序值</text>
<input
class="modal-input"
type="number"
v-model="form.sortOrderStr"
placeholder="越小越靠前"
placeholder-style="color:#bbb"
/>
</view>
<view class="modal-field modal-field--last">
<text class="modal-label">活动说明</text>
<textarea
class="modal-textarea"
v-model="form.description"
placeholder="可选,向用户展示"
placeholder-style="color:#bbb"
:maxlength="500"
auto-height
/>
</view>
</view>
<!-- Action buttons -->
<view class="modal-actions">
<view class="modal-cancel" @tap="closeModal">
<text class="modal-cancel-text">取消</text>
</view>
<view
class="modal-confirm"
:class="{ 'modal-confirm--loading': submitting }"
@tap="submitForm"
>
<text class="modal-confirm-text">{{ submitting ? '保存中...' : '确认保存' }}</text>
</view>
</view>
</scroll-view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from '../../stores/admin'
import { formatPrice, formatDateTime, getFlashSalePhaseLabel, getStockPercent, formatDateLocal, formatTimeLocal } from '../../utils/format'
import { FlashSaleStatus, FlashSalePhase } from '@mp-pilates/shared'
import type { FlashSaleAdminItem, CardType } from '@mp-pilates/shared'
const adminStore = useAdminStore()
const navBarHeight = ref('64px')
onMounted(() => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
})
// ─── Data ────────────────────────────────────────────
const items = ref<FlashSaleAdminItem[]>([])
const total = ref(0)
const pageLoading = ref(false)
const showModal = ref(false)
const submitting = ref(false)
const editTarget = ref<FlashSaleAdminItem | null>(null)
const cardTypes = ref<CardType[]>([])
const cardTypeOptions = computed(() =>
cardTypes.value.map((ct) => ({
label: `${ct.name}(¥${formatPrice(ct.price)}`,
value: ct.id,
})),
)
const defaultForm = () => ({
cardTypeIdx: 0,
title: '',
originalPriceStr: '',
flashPriceStr: '',
totalStockStr: '',
startDate: '',
startTimeStr: '',
endDate: '',
endTimeStr: '',
sortOrderStr: '0',
description: '',
})
const form = ref(defaultForm())
// ─── Data loading ─────────────────────────────────────
async function loadData() {
pageLoading.value = true
try {
const [salesResult, cardTypesResult] = await Promise.all([
adminStore.fetchFlashSales(),
adminStore.fetchCardTypes(),
])
items.value = [...salesResult.items]
total.value = salesResult.total
cardTypes.value = [...cardTypesResult]
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
pageLoading.value = false
}
}
async function reloadSales() {
try {
const result = await adminStore.fetchFlashSales()
items.value = [...result.items]
total.value = result.total
} catch {
// silent
}
}
// ─── Helpers ──────────────────────────────────────────
function phaseLabel(phase: FlashSalePhase): string {
return getFlashSalePhaseLabel(phase)
}
function phaseTagClass(phase: FlashSalePhase): string {
if (phase === FlashSalePhase.ONGOING) return 'tag--ongoing'
if (phase === FlashSalePhase.UPCOMING) return 'tag--upcoming'
if (phase === FlashSalePhase.SOLD_OUT) return 'tag--soldout'
return 'tag--ended'
}
function headerStatusClass(item: FlashSaleAdminItem): string {
if (item.status === FlashSaleStatus.DRAFT) return 'header--draft'
if (item.status === FlashSaleStatus.ENDED) return 'header--ended'
return 'header--active'
}
function stockPercent(item: FlashSaleAdminItem): string {
return getStockPercent(item.soldCount, item.totalStock)
}
// ─── Modal ────────────────────────────────────────────
function openAdd() {
editTarget.value = null
form.value = defaultForm()
showModal.value = true
}
function openEdit(item: FlashSaleAdminItem) {
editTarget.value = item
const startDt = new Date(item.startTime)
const endDt = new Date(item.endTime)
const ctIdx = cardTypes.value.findIndex((ct) => ct.id === item.cardTypeId)
form.value = {
cardTypeIdx: ctIdx >= 0 ? ctIdx : 0,
title: item.title,
originalPriceStr: String(item.originalPrice / 100),
flashPriceStr: String(item.flashPrice / 100),
totalStockStr: String(item.totalStock),
startDate: formatDateLocal(startDt),
startTimeStr: formatTimeLocal(startDt),
endDate: formatDateLocal(endDt),
endTimeStr: formatTimeLocal(endDt),
sortOrderStr: String(item.sortOrder),
description: item.description ?? '',
}
showModal.value = true
}
function closeModal() {
showModal.value = false
editTarget.value = null
}
function onCardTypeChange(e: { detail: { value: number } }) {
const idx = Number(e.detail.value)
form.value.cardTypeIdx = idx
// Auto-fill original price from card type
const ct = cardTypes.value[idx]
if (ct && !form.value.originalPriceStr) {
form.value.originalPriceStr = String(Number(ct.price) / 100)
}
}
function onStartDateChange(e: { detail: { value: string } }) {
form.value.startDate = e.detail.value
}
function onStartTimeChange(e: { detail: { value: string } }) {
form.value.startTimeStr = e.detail.value
}
function onEndDateChange(e: { detail: { value: string } }) {
form.value.endDate = e.detail.value
}
function onEndTimeChange(e: { detail: { value: string } }) {
form.value.endTimeStr = e.detail.value
}
// ─── Form submit ──────────────────────────────────────
async function submitForm() {
if (submitting.value) return
if (!form.value.title.trim()) {
uni.showToast({ title: '请填写活动标题', icon: 'none' }); return
}
const originalPrice = parseFloat(form.value.originalPriceStr)
if (isNaN(originalPrice) || originalPrice <= 0) {
uni.showToast({ title: '请填写有效原价', icon: 'none' }); return
}
const flashPrice = parseFloat(form.value.flashPriceStr)
if (isNaN(flashPrice) || flashPrice <= 0) {
uni.showToast({ title: '请填写有效秒杀价', icon: 'none' }); return
}
const totalStock = parseInt(form.value.totalStockStr, 10)
if (isNaN(totalStock) || totalStock < 1) {
uni.showToast({ title: '请填写有效库存', icon: 'none' }); return
}
if (!form.value.startDate || !form.value.startTimeStr) {
uni.showToast({ title: '请选择开始时间', icon: 'none' }); return
}
if (!form.value.endDate || !form.value.endTimeStr) {
uni.showToast({ title: '请选择结束时间', icon: 'none' }); return
}
const startTime = `${form.value.startDate}T${form.value.startTimeStr}:00`
const endTime = `${form.value.endDate}T${form.value.endTimeStr}:00`
if (new Date(endTime) <= new Date(startTime)) {
uni.showToast({ title: '结束时间须晚于开始时间', icon: 'none' }); return
}
submitting.value = true
try {
if (editTarget.value) {
await adminStore.updateFlashSale(editTarget.value.id, {
title: form.value.title.trim(),
originalPrice: Math.round(originalPrice * 100),
flashPrice: Math.round(flashPrice * 100),
totalStock,
startTime,
endTime,
description: form.value.description.trim() || undefined,
sortOrder: parseInt(form.value.sortOrderStr, 10) || 0,
})
} else {
const selectedCardType = cardTypes.value[form.value.cardTypeIdx]
if (!selectedCardType) {
uni.showToast({ title: '请选择卡种', icon: 'none' }); return
}
await adminStore.createFlashSale({
cardTypeId: selectedCardType.id,
title: form.value.title.trim(),
originalPrice: Math.round(originalPrice * 100),
flashPrice: Math.round(flashPrice * 100),
totalStock,
startTime,
endTime,
description: form.value.description.trim() || undefined,
sortOrder: parseInt(form.value.sortOrderStr, 10) || 0,
})
}
uni.showToast({ title: '保存成功', icon: 'success' })
closeModal()
await reloadSales()
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '保存失败'
uni.showToast({ title: msg, icon: 'none' })
} finally {
submitting.value = false
}
}
// ─── Actions ──────────────────────────────────────────
function confirmActivate(item: FlashSaleAdminItem) {
uni.showModal({
title: '确认上线',
content: `上线后「${item.title}」将对用户可见,到达秒杀时间后用户可抢购。`,
confirmText: '上线',
confirmColor: '#27ae60',
success: async (res) => {
if (!res.confirm) return
uni.showLoading({ title: '上线中...' })
try {
await adminStore.updateFlashSale(item.id, { status: FlashSaleStatus.ACTIVE })
uni.hideLoading()
uni.showToast({ title: '已上线', icon: 'success' })
await reloadSales()
} catch {
uni.hideLoading()
uni.showToast({ title: '上线失败', icon: 'none' })
}
},
})
}
function confirmEnd(item: FlashSaleAdminItem) {
uni.showModal({
title: '确认结束',
content: `结束后「${item.title}」将停止售卖,已购买的不受影响。`,
confirmText: '结束',
confirmColor: '#e67e22',
success: async (res) => {
if (!res.confirm) return
uni.showLoading({ title: '结束中...' })
try {
await adminStore.updateFlashSale(item.id, { status: FlashSaleStatus.ENDED })
uni.hideLoading()
uni.showToast({ title: '已结束', icon: 'success' })
await reloadSales()
} catch {
uni.hideLoading()
uni.showToast({ title: '操作失败', icon: 'none' })
}
},
})
}
function confirmDelete(item: FlashSaleAdminItem) {
uni.showModal({
title: '确认删除',
content: `确定删除「${item.title}」?此操作不可恢复。`,
confirmText: '删除',
confirmColor: '#c0392b',
success: async (res) => {
if (!res.confirm) return
uni.showLoading({ title: '删除中...' })
try {
await adminStore.deleteFlashSale(item.id)
uni.hideLoading()
uni.showToast({ title: '已删除', icon: 'success' })
await reloadSales()
} catch {
uni.hideLoading()
uni.showToast({ title: '删除失败', icon: 'none' })
}
},
})
}
// ─── Lifecycle ────────────────────────────────────────
onMounted(loadData)
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #f5f3f0;
padding-bottom: 40rpx;
}
/* ── Toolbar ─────────────────────────────── */
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 24rpx 16rpx;
}
.toolbar-hint { font-size: 24rpx; color: #999; }
.add-btn {
background: linear-gradient(135deg, #D4A59A, #C08B7E);
border-radius: 32rpx;
padding: 12rpx 28rpx;
display: flex;
align-items: center;
gap: 8rpx;
}
.add-btn-text { font-size: 26rpx; font-weight: 600; color: #fff; }
/* ── Skeleton ────────────────────────────── */
.skeleton-list { padding: 0 24rpx; }
.skeleton-item {
height: 300rpx;
border-radius: 16rpx;
margin-bottom: 20rpx;
background: linear-gradient(90deg, #f0ece8 25%, #e8e4df 50%, #f0ece8 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
/* ── Empty ───────────────────────────────── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 100rpx 0;
gap: 20rpx;
}
.empty-icon { font-size: 80rpx; }
.empty-text { font-size: 28rpx; color: #bbb; }
/* ── Flash sale list ─────────────────────── */
.fs-list { padding: 0 24rpx; }
.fs-card {
background: #fff;
border-radius: 16rpx;
overflow: hidden;
margin-bottom: 20rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.08);
}
.fs-header {
padding: 20rpx 24rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
.header--active { background: linear-gradient(90deg, #D4A59A, #C08B7E); }
.header--draft { background: linear-gradient(90deg, #AEA49A, #9E948A); }
.header--ended { background: linear-gradient(90deg, #B0A898, #9A928A); }
.fs-header-left { flex: 1; min-width: 0; }
.fs-title {
font-size: 28rpx;
font-weight: 700;
color: #fff;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fs-status-tag {
border-radius: 20rpx;
padding: 4rpx 16rpx;
flex-shrink: 0;
margin-left: 12rpx;
}
.tag--ongoing { background: rgba(255, 255, 255, 0.3); }
.tag--upcoming { background: rgba(255, 255, 255, 0.2); }
.tag--soldout { background: rgba(0, 0, 0, 0.2); }
.tag--ended { background: rgba(0, 0, 0, 0.3); }
.fs-status-text { font-size: 20rpx; color: #fff; font-weight: 600; }
.fs-body { padding: 24rpx; }
.fs-info-row { margin-bottom: 16rpx; }
.fs-card-type { font-size: 24rpx; color: #888; }
.fs-price-row {
display: flex;
gap: 32rpx;
margin-bottom: 16rpx;
}
.fs-price-block {
display: flex;
flex-direction: column;
gap: 4rpx;
}
.fs-price-label { font-size: 20rpx; color: #aaa; }
.fs-price-value {
font-size: 30rpx;
font-weight: 700;
color: #333;
&.flash { color: #B5725E; }
&.original { color: #aaa; text-decoration: line-through; font-weight: 400; }
}
/* Stock progress bar */
.fs-stock-bar {
height: 8rpx;
background: #f0f0f0;
border-radius: 4rpx;
overflow: hidden;
margin-bottom: 12rpx;
}
.fs-stock-fill {
height: 100%;
background: linear-gradient(90deg, #D4A59A, #C08B7E);
border-radius: 4rpx;
transition: width 0.3s;
}
.fs-time-row { margin-top: 4rpx; }
.fs-time { font-size: 22rpx; color: #999; }
/* ── Actions ─────────────────────────────── */
.fs-actions {
display: flex;
border-top: 1rpx solid #f5f5f5;
}
.fs-action-btn {
flex: 1;
padding: 20rpx 0;
display: flex;
align-items: center;
justify-content: center;
border-right: 1rpx solid #f5f5f5;
&:last-child { border-right: none; }
&:active { background: #f9f9f9; }
}
.fs-action-text { font-size: 26rpx; font-weight: 600; }
.edit-btn .fs-action-text { color: #1a1a2e; }
.activate-btn .fs-action-text { color: #27ae60; }
.end-btn .fs-action-text { color: #e67e22; }
.delete-btn .fs-action-text { color: #c0392b; }
/* ── Modal ───────────────────────────────── */
.modal-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-end;
z-index: 1000;
}
.modal-container {
width: 100%;
max-height: 85vh;
background: #fff;
border-radius: 24rpx 24rpx 0 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.modal-scroll { flex: 1; max-height: 85vh; }
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx 32rpx 16rpx;
position: sticky;
top: 0;
background: #fff;
z-index: 10;
}
.modal-title { font-size: 32rpx; font-weight: 700; color: #1a1a2e; }
.modal-close {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
border-radius: 50%;
}
.modal-close-icon { font-size: 24rpx; color: #999; }
.modal-body { padding: 0 32rpx; }
.modal-field {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 0;
border-bottom: 1rpx solid #f5f5f5;
gap: 16rpx;
&--last { border-bottom: none; align-items: flex-start; }
}
.modal-label { font-size: 26rpx; color: #555; width: 160rpx; flex-shrink: 0; }
.modal-input { flex: 1; text-align: right; font-size: 26rpx; color: #222; }
.picker-display { display: flex; align-items: center; gap: 8rpx; }
.picker-text { font-size: 26rpx; color: #222; }
.picker-arrow { font-size: 26rpx; color: #bbb; }
.datetime-picker-group {
display: flex;
align-items: center;
gap: 12rpx;
flex: 1;
justify-content: flex-end;
}
.datetime-text {
font-size: 26rpx;
color: #222;
padding: 8rpx 16rpx;
background: #f8f8f8;
border-radius: 8rpx;
}
.modal-textarea {
flex: 1;
font-size: 26rpx;
color: #222;
min-height: 80rpx;
text-align: right;
}
.modal-actions {
display: flex;
gap: 16rpx;
padding: 24rpx 32rpx calc(24rpx + env(safe-area-inset-bottom));
background: #fff;
}
.modal-cancel {
flex: 1;
height: 88rpx;
background: #f0f0f0;
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
&:active { background: #e8e8e8; }
}
.modal-cancel-text { font-size: 28rpx; color: #555; }
.modal-confirm {
flex: 2;
height: 88rpx;
background: linear-gradient(90deg, #D4A59A, #C08B7E);
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
&:active { opacity: 0.85; }
&--loading { opacity: 0.6; pointer-events: none; }
}
.modal-confirm-text { font-size: 28rpx; font-weight: 700; color: #fff; }
</style>

View File

@@ -63,21 +63,6 @@
<text class="arrow-text"></text>
</view>
</view>
<view class="list-item" @tap="navigate('/pages/admin/week-template')">
<view class="item-left">
<view class="item-icon-wrap icon--template">
<text class="item-icon-text"></text>
</view>
<view class="item-text-group">
<text class="item-title">排课模板</text>
<text class="item-desc">设置每周课程模板</text>
</view>
</view>
<view class="item-arrow">
<text class="arrow-text"></text>
</view>
</view>
</view>
<!-- Section header: 会员与订单 -->
@@ -131,6 +116,21 @@
<text class="arrow-text"></text>
</view>
</view>
<view class="list-item" @tap="navigate('/pages/admin/flash-sales')">
<view class="item-left">
<view class="item-icon-wrap icon--flash-sale">
<text class="item-icon-text"></text>
</view>
<view class="item-text-group">
<text class="item-title">秒杀管理</text>
<text class="item-desc">创建和管理限时秒杀活动</text>
</view>
</view>
<view class="item-arrow">
<text class="arrow-text"></text>
</view>
</view>
</view>
<!-- Section header: 系统 -->
@@ -154,6 +154,21 @@
<text class="arrow-text"></text>
</view>
</view>
<view class="list-item" @tap="handleIncreaseSubscriptionCount">
<view class="item-left">
<view class="item-icon-wrap icon--subscribe">
<text class="item-icon-text"></text>
</view>
<view class="item-text-group">
<text class="item-title">增加订阅次数</text>
<text class="item-desc">当前剩余 {{ user?.adminBookingSubscriptionCount ?? 0 }} </text>
</view>
</view>
<view class="item-arrow">
<text class="arrow-text">{{ adminSubscribeLoading ? '...' : '' }}</text>
</view>
</view>
</view>
<view style="height: 40rpx" />
@@ -162,17 +177,24 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from '../../stores/admin'
import { useUserStore } from '../../stores/user'
import type { AdminStats } from '../../stores/admin'
import { requestAdminBookingSubscriptionCount } from '../../utils/wechat-subscription'
import { getErrorMessage } from '../../utils/auth'
const navBarHeight = ref('64px')
const adminStore = useAdminStore()
const userStore = useUserStore()
const { user } = storeToRefs(userStore)
const statsLoading = ref(false)
const stats = ref<AdminStats>({ todayBookings: 0, totalOrders: 0, totalBookings: 0 })
const adminSubscribeLoading = ref(false)
function navigate(path: string) {
uni.navigateTo({ url: path })
@@ -189,9 +211,32 @@ async function loadStats() {
}
}
async function handleIncreaseSubscriptionCount() {
if (adminSubscribeLoading.value) {
return
}
adminSubscribeLoading.value = true
try {
const profile = await requestAdminBookingSubscriptionCount()
if (!profile) {
uni.showToast({ title: '已取消本次授权', icon: 'none' })
return
}
userStore.setProfile(profile)
uni.showToast({ title: `订阅次数 +1剩余 ${profile.adminBookingSubscriptionCount}`, icon: 'none' })
} catch (err: unknown) {
uni.showToast({ title: getErrorMessage(err, '订阅授权失败'), icon: 'none' })
} finally {
adminSubscribeLoading.value = false
}
}
onMounted(() => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
loadStats()
userStore.fetchProfile()
})
</script>
@@ -330,7 +375,9 @@ onMounted(() => {
.icon--members { background: linear-gradient(135deg, $primary-color, $primary-dark); }
.icon--orders { background: linear-gradient(135deg, #7E9EC4, #6E8EB4); }
.icon--card { background: linear-gradient(135deg, #C48E7E, #B47E6E); }
.icon--flash-sale { background: linear-gradient(135deg, #D4A59A, #C08B7E); }
.icon--studio { background: linear-gradient(135deg, #9E9E7E, #8E8E6E); }
.icon--subscribe { background: linear-gradient(135deg, #5D8C8A, #476D72); }
.item-text-group {
display: flex;

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<view class="page" :style="{ '--status-bar': statusBarHeight + 'px' }">
<CustomNavBar title="订单管理" show-back />
<!-- Summary stats bar -->
@@ -44,7 +44,9 @@
class="list-scroll"
:refresher-enabled="true"
:refresher-triggered="refreshing"
:lower-threshold="120"
@refresherrefresh="onRefresh"
@scrolltolower="loadMore"
>
<!-- Loading skeleton -->
<view v-if="loading && !orders.length" class="order-list">
@@ -106,21 +108,21 @@
</view>
<view class="info-right">
<text class="info-label">下单时间</text>
<text class="info-value">{{ formatDate(order.createdAt) }}</text>
<text class="info-value">{{ formatDateTime(order.createdAt) }}</text>
</view>
</view>
<!-- Paid time if available -->
<view v-if="order.paidAt && order.status === OrderStatus.PAID" class="info-row">
<text class="info-label">支付时间</text>
<text class="info-value">{{ formatDate(order.paidAt) }}</text>
<text class="info-value">{{ formatDateTime(order.paidAt) }}</text>
</view>
</view>
</view>
</view>
<!-- Load more / no more -->
<view v-if="hasMore" class="load-more" @tap="loadMore">
<view v-if="hasMore" class="load-more">
<text class="load-more-text">{{ loading ? '加载中...' : '加载更多' }}</text>
</view>
<view v-else-if="orders.length > 0" class="no-more">
@@ -135,17 +137,19 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from '../../stores/admin'
import { formatPrice, formatDate } from '../../utils/format'
import { formatPrice, formatDateTime } from '../../utils/format'
import { OrderStatus } from '@mp-pilates/shared'
import type { OrderWithDetails } from '@mp-pilates/shared'
const adminStore = useAdminStore()
const navBarHeight = ref('64px')
// 动态计算顶部模块高度
const statusBarHeight = ref(0)
onMounted(() => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
const windowInfo = uni.getWindowInfo()
statusBarHeight.value = windowInfo.statusBarHeight ?? 20
})
const filters = [
@@ -165,6 +169,21 @@ const totalCount = ref<number | null>(null)
const paidCount = ref<number | null>(null)
const pendingCount = ref<number | null>(null)
// 每个 tab 单独缓存数据
const orderCache: Record<string, { items: OrderWithDetails[]; total: number; page: number; hasMore: boolean }> = {}
function getCacheKey(filter: string): string {
return filter || 'all'
}
function getCachedData(filter: string) {
return orderCache[getCacheKey(filter)]
}
function setCachedData(filter: string, data: { items: OrderWithDetails[]; total: number; page: number; hasMore: boolean }) {
orderCache[getCacheKey(filter)] = data
}
const LIMIT = 20
function statusLabel(s: string) {
@@ -191,23 +210,45 @@ function statusAccentClass(s: string) {
}
async function loadOrders(reset = false) {
const filter = activeFilter.value
// 如果有缓存且是重置切换tab直接用缓存数据
if (reset) {
const cached = getCachedData(filter)
if (cached) {
orders.value = [...cached.items]
hasMore.value = cached.hasMore
page.value = cached.page
return
}
}
// 初始加载或下拉刷新,需要请求接口
if (loading.value) return
if (reset) page.value = 1
loading.value = true
try {
const params: { page: number; limit: number; status?: string } = {
page: page.value,
limit: LIMIT,
}
if (activeFilter.value) params.status = activeFilter.value
if (filter) params.status = filter
const result = await adminStore.fetchAdminOrders(params)
if (reset) {
orders.value = [...result.data]
} else {
orders.value.push(...result.data)
}
hasMore.value = orders.value.length < result.total
totalCount.value = result.total
const newItems = reset ? [...result.items] : [...orders.value, ...result.items]
const newHasMore = newItems.length < result.total
// 缓存数据
setCachedData(filter, {
items: newItems,
total: result.total,
page: page.value,
hasMore: newHasMore,
})
orders.value = newItems
hasMore.value = newHasMore
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
@@ -216,30 +257,79 @@ async function loadOrders(reset = false) {
}
}
async function loadSummaryCounts() {
// 初始加载所有分类的数据
async function loadAllFiltersData() {
loading.value = true
try {
const [allResult, paidResult, pendingResult] = await Promise.all([
adminStore.fetchAdminOrders({ page: 1, limit: 1 }),
adminStore.fetchAdminOrders({ page: 1, limit: 1, status: OrderStatus.PAID }),
adminStore.fetchAdminOrders({ page: 1, limit: 1, status: OrderStatus.PENDING }),
// 并行请求所有分类(第一页数据)
const [allResult, paidResult, pendingResult, refundedResult] = await Promise.all([
adminStore.fetchAdminOrders({ page: 1, limit: LIMIT }),
adminStore.fetchAdminOrders({ page: 1, limit: LIMIT, status: OrderStatus.PAID }),
adminStore.fetchAdminOrders({ page: 1, limit: LIMIT, status: OrderStatus.PENDING }),
adminStore.fetchAdminOrders({ page: 1, limit: LIMIT, status: OrderStatus.REFUNDED }),
])
// 缓存全部
setCachedData('', {
items: [...allResult.items],
total: allResult.total,
page: 1,
hasMore: allResult.items.length < allResult.total,
})
totalCount.value = allResult.total
// 缓存已支付
setCachedData(OrderStatus.PAID, {
items: [...paidResult.items],
total: paidResult.total,
page: 1,
hasMore: paidResult.items.length < paidResult.total,
})
paidCount.value = paidResult.total
// 缓存待支付
setCachedData(OrderStatus.PENDING, {
items: [...pendingResult.items],
total: pendingResult.total,
page: 1,
hasMore: pendingResult.items.length < pendingResult.total,
})
pendingCount.value = pendingResult.total
// 缓存已退款
setCachedData(OrderStatus.REFUNDED, {
items: [...refundedResult.items],
total: refundedResult.total,
page: 1,
hasMore: refundedResult.items.length < refundedResult.total,
})
// 设置当前 tab 的数据
orders.value = [...allResult.items]
hasMore.value = allResult.items.length < allResult.total
} catch {
// non-critical, ignore
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
refreshing.value = false
}
}
function selectFilter(value: string) {
activeFilter.value = value
totalCount.value = null
loadOrders(true)
// 切换 tab 直接从缓存读取
const cached = getCachedData(value)
if (cached) {
orders.value = [...cached.items]
hasMore.value = cached.hasMore
page.value = cached.page
}
}
async function onRefresh() {
refreshing.value = true
await Promise.all([loadOrders(true), loadSummaryCounts()])
// 下拉刷新重新请求所有分类的数据
await loadAllFiltersData()
}
function loadMore() {
@@ -249,8 +339,7 @@ function loadMore() {
}
onMounted(() => {
loadOrders(true)
loadSummaryCounts()
loadAllFiltersData()
})
</script>
@@ -266,13 +355,18 @@ onMounted(() => {
/* ── Stats bar ──────────────────────────────── */
.stats-bar {
position: fixed;
top: calc(var(--status-bar) + 44px);
left: 0;
right: 0;
display: flex;
align-items: center;
height: 96rpx;
background: #FFFFFF;
padding: 28rpx 0;
margin: 0;
padding: 0;
border-bottom: 1rpx solid rgba(180, 160, 130, 0.2);
box-shadow: 0 2rpx 12rpx rgba(180, 160, 130, 0.08);
z-index: 100;
}
.stat-item {
@@ -309,9 +403,13 @@ onMounted(() => {
/* ── Filter pills ───────────────────────────── */
.filter-wrap {
position: fixed;
top: calc(var(--status-bar) + 92px);
left: 0;
right: 0;
background: #FAF8F5;
border-bottom: 1rpx solid rgba(180, 160, 130, 0.15);
flex-shrink: 0;
z-index: 99;
}
.filter-scroll { overflow: hidden; }
@@ -364,7 +462,11 @@ onMounted(() => {
/* ── List ───────────────────────────────────── */
.list-scroll {
flex: 1;
position: fixed;
top: calc(var(--status-bar) + 144px);
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
}

View File

@@ -15,7 +15,7 @@
<view v-else-if="editableSlots.length === 0" class="empty-state">
<text class="empty-icon">📭</text>
<text class="empty-text">当日暂无排课</text>
<text class="empty-sub">无模板匹配请手动添加时段或先配置排课模板</text>
<text class="empty-sub">当日暂无默认时段请点击下方按钮手动添加</text>
</view>
<!-- Slot list -->
@@ -404,7 +404,7 @@ function slotBadgeClass(slot: EditableSlot): string {
function slotBadgeText(slot: EditableSlot): string {
if (slot.isNew) return '新增'
if (slot.isPublished) return '已发布'
return '来自模板'
return '默认时段'
}
// ── Lifecycle ─────────────────────────────────────────────

View File

@@ -128,7 +128,7 @@
</picker>
</view>
</view>
<text class="gen-hint">根据排课模板自动生成所选日期范围内的时段</text>
<text class="gen-hint">按默认时间表每天 8:00-22:00每小时一节自动生成所选日期范围内的时段</text>
<view class="action-wrap">
<view class="action-btn" :class="{ 'action-btn--loading': submitting }" @tap="submitGenerate">
<text class="action-btn-text">{{ submitting ? '生成中...' : '批量生成' }}</text>

File diff suppressed because it is too large Load Diff

View File

@@ -1,528 +0,0 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="排课模板" show-back />
<!-- Toolbar -->
<view class="toolbar">
<text class="toolbar-hint"> {{ templates.length }} 条模板</text>
<view class="add-btn" @tap="openAdd">
<text class="add-btn-text"> 新增时段</text>
</view>
</view>
<!-- Loading skeleton -->
<view v-if="loading" class="skeleton-list">
<view v-for="i in 5" :key="i" class="skeleton-item" />
</view>
<!-- Empty -->
<view v-else-if="!templates.length" class="empty-state">
<text class="empty-icon">📅</text>
<text class="empty-text">暂无模板点击右上角新增</text>
</view>
<!-- Template list grouped by weekday -->
<view v-else>
<view v-for="(group, day) in grouped" :key="day" class="day-group">
<view class="day-header">
<text class="day-label">{{ WEEKDAY_LABELS[Number(day)] }}</text>
<text class="day-count">{{ group.length }} 个时段</text>
</view>
<view
v-for="tpl in group"
:key="tpl.id ?? tpl._key"
class="tpl-row"
:class="{ 'tpl-row--inactive': !tpl.isActive }"
>
<view class="tpl-time">
<text class="tpl-time-text">{{ tpl.startTime }} {{ tpl.endTime }}</text>
<text class="tpl-capacity">{{ tpl.capacity }} </text>
</view>
<view class="tpl-actions">
<view
class="tpl-toggle"
:class="tpl.isActive ? 'toggle--on' : 'toggle--off'"
@tap="toggleTemplate(tpl)"
>
<text class="tpl-toggle-text">{{ tpl.isActive ? '启用' : '停用' }}</text>
</view>
<view class="tpl-edit" @tap="openEdit(tpl)">
<text class="tpl-edit-text">编辑</text>
</view>
<view class="tpl-delete" @tap="deleteTemplate(tpl)">
<text class="tpl-delete-text">删除</text>
</view>
</view>
</view>
</view>
</view>
<!-- Save bar -->
<view v-if="isDirty" class="save-bar">
<view class="save-btn" :class="{ 'save-btn--loading': saving }" @tap="handleSave">
<text class="save-btn-text">{{ saving ? '保存中...' : '保存全部更改' }}</text>
</view>
</view>
<!-- Add / Edit modal -->
<view v-if="showModal" class="modal-mask" @tap.self="closeModal">
<view class="modal">
<text class="modal-title">{{ editTarget ? '编辑时段' : '新增时段' }}</text>
<view class="modal-field">
<text class="modal-label">星期</text>
<picker
mode="selector"
:range="dayOptions"
range-key="label"
:value="form.dayIdx"
@change="(e: any) => form.dayIdx = Number(e.detail.value)"
>
<view class="picker-display">
<text class="picker-text">{{ dayOptions[form.dayIdx].label }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<view class="modal-field">
<text class="modal-label">开始时间</text>
<picker
mode="time"
:value="form.startTime"
@change="(e: any) => form.startTime = e.detail.value"
>
<view class="picker-display">
<text class="picker-text">{{ form.startTime || '请选择' }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<view class="modal-field">
<text class="modal-label">结束时间</text>
<picker
mode="time"
:value="form.endTime"
@change="(e: any) => form.endTime = e.detail.value"
>
<view class="picker-display">
<text class="picker-text">{{ form.endTime || '请选择' }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<view class="modal-field modal-field--last">
<text class="modal-label">容量</text>
<input
class="modal-input"
type="number"
v-model="form.capacityStr"
placeholder="如10"
placeholder-style="color:#bbb"
/>
</view>
<view class="modal-actions">
<view class="modal-cancel" @tap="closeModal">
<text class="modal-cancel-text">取消</text>
</view>
<view class="modal-confirm" @tap="submitForm">
<text class="modal-confirm-text">确认</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from '../../stores/admin'
import { WEEKDAY_LABELS } from '@mp-pilates/shared'
import type { WeekTemplate } from '@mp-pilates/shared'
type LocalTemplate = Partial<WeekTemplate> & {
_key?: string
dayOfWeek: number
startTime: string
endTime: string
capacity: number
isActive: boolean
}
const adminStore = useAdminStore()
const navBarHeight = ref('64px')
const loading = ref(false)
const saving = ref(false)
const isDirty = ref(false)
const showModal = ref(false)
const editTarget = ref<LocalTemplate | null>(null)
const templates = ref<LocalTemplate[]>([])
const dayOptions = [1, 2, 3, 4, 5, 6, 7].map((d) => ({ label: WEEKDAY_LABELS[d], value: d }))
const form = ref({
dayIdx: 0,
startTime: '08:00',
endTime: '09:00',
capacityStr: '1',
})
const grouped = computed(() => {
const map: Record<number, LocalTemplate[]> = {}
for (const tpl of templates.value) {
if (!map[tpl.dayOfWeek]) map[tpl.dayOfWeek] = []
map[tpl.dayOfWeek].push(tpl)
}
// Sort by day
return Object.fromEntries(
Object.entries(map).sort(([a], [b]) => Number(a) - Number(b)),
)
})
/** 生成默认模板周一到周日8:00-22:00 每小时一个时段 */
function generateDefaultTemplates(): LocalTemplate[] {
const defaults: LocalTemplate[] = []
for (let day = 1; day <= 7; day++) {
for (let hour = 8; hour < 22; hour++) {
const start = String(hour).padStart(2, '0') + ':00'
const end = String(hour + 1).padStart(2, '0') + ':00'
defaults.push({
_key: `default-${day}-${start}`,
dayOfWeek: day,
startTime: start,
endTime: end,
capacity: 1,
isActive: true,
})
}
}
return defaults
}
async function fetchTemplates() {
loading.value = true
try {
const data = await adminStore.fetchWeekTemplates()
if (data.length === 0) {
// No templates yet — pre-fill with defaults
templates.value = generateDefaultTemplates()
isDirty.value = true
} else {
templates.value = data
isDirty.value = false
}
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
function openAdd() {
editTarget.value = null
form.value = { dayIdx: 0, startTime: '08:00', endTime: '09:00', capacityStr: '1' }
showModal.value = true
}
function openEdit(tpl: LocalTemplate) {
editTarget.value = tpl
const dayIdx = dayOptions.findIndex((d) => d.value === tpl.dayOfWeek)
form.value = {
dayIdx: dayIdx >= 0 ? dayIdx : 0,
startTime: tpl.startTime,
endTime: tpl.endTime,
capacityStr: String(tpl.capacity),
}
showModal.value = true
}
function closeModal() {
showModal.value = false
editTarget.value = null
}
function submitForm() {
const capacity = parseInt(form.value.capacityStr, 10)
if (!form.value.startTime || !form.value.endTime) {
uni.showToast({ title: '请填写时间', icon: 'none' })
return
}
if (isNaN(capacity) || capacity < 1) {
uni.showToast({ title: '请填写有效容量', icon: 'none' })
return
}
const day = dayOptions[form.value.dayIdx].value
if (editTarget.value) {
const tpl = editTarget.value
tpl.dayOfWeek = day
tpl.startTime = form.value.startTime
tpl.endTime = form.value.endTime
tpl.capacity = capacity
} else {
templates.value.push({
_key: String(Date.now()),
dayOfWeek: day,
startTime: form.value.startTime,
endTime: form.value.endTime,
capacity,
isActive: true,
})
}
isDirty.value = true
closeModal()
}
function toggleTemplate(tpl: LocalTemplate) {
tpl.isActive = !tpl.isActive
isDirty.value = true
}
function deleteTemplate(tpl: LocalTemplate) {
uni.showModal({
title: '确认删除',
content: '删除该时段模板?',
success: (res) => {
if (res.confirm) {
const idx = templates.value.indexOf(tpl)
if (idx >= 0) templates.value.splice(idx, 1)
isDirty.value = true
}
},
})
}
async function handleSave() {
if (saving.value) return
saving.value = true
try {
const payload = templates.value.map((t) => ({
id: t.id,
dayOfWeek: t.dayOfWeek,
startTime: t.startTime,
endTime: t.endTime,
capacity: t.capacity,
isActive: t.isActive,
}))
await adminStore.saveWeekTemplates(payload as any)
isDirty.value = false
uni.showToast({ title: '保存成功', icon: 'success' })
await fetchTemplates()
} catch (e: any) {
uni.showToast({ title: e?.message ?? '保存失败', icon: 'none' })
} finally {
saving.value = false
}
}
onMounted(() => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
fetchTemplates()
})
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #f5f3f0;
padding-bottom: 120rpx;
}
/* ── Toolbar ─────────────────────────────── */
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 24rpx 16rpx;
}
.toolbar-hint { font-size: 24rpx; color: #999; }
.add-btn {
background: #1a1a2e;
border-radius: 32rpx;
padding: 12rpx 28rpx;
}
.add-btn-text { font-size: 26rpx; font-weight: 600; color: $primary-dark; }
/* ── Skeleton ────────────────────────────── */
.skeleton-list { padding: 0 24rpx; }
.skeleton-item {
height: 80rpx;
border-radius: 12rpx;
margin-bottom: 16rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
/* ── Empty ───────────────────────────────── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 100rpx 0;
gap: 20rpx;
}
.empty-icon { font-size: 80rpx; }
.empty-text { font-size: 28rpx; color: #bbb; }
/* ── Day group ───────────────────────────── */
.day-group { margin: 0 24rpx 24rpx; }
.day-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rpx 8rpx;
}
.day-label { font-size: 28rpx; font-weight: 700; color: #1a1a2e; }
.day-count { font-size: 22rpx; color: #999; }
/* ── Template row ────────────────────────── */
.tpl-row {
background: #ffffff;
border-radius: 12rpx;
padding: 20rpx 24rpx;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.06);
&--inactive { opacity: 0.5; }
}
.tpl-time { display: flex; flex-direction: column; gap: 6rpx; }
.tpl-time-text { font-size: 28rpx; font-weight: 600; color: #1a1a2e; }
.tpl-capacity { font-size: 22rpx; color: #888; }
.tpl-actions { display: flex; gap: 12rpx; }
.tpl-toggle,
.tpl-edit,
.tpl-delete {
border-radius: 20rpx;
padding: 8rpx 20rpx;
}
.toggle--on { background: rgba(39,174,96,0.12); }
.toggle--on .tpl-toggle-text { font-size: 24rpx; color: #27ae60; }
.toggle--off { background: rgba(230,126,34,0.12); }
.toggle--off .tpl-toggle-text { font-size: 24rpx; color: #e67e22; }
.tpl-edit { background: rgba(26,26,46,0.08); }
.tpl-edit-text { font-size: 24rpx; color: #1a1a2e; }
.tpl-delete { background: rgba(192,57,43,0.08); }
.tpl-delete-text { font-size: 24rpx; color: #c0392b; }
/* ── Save bar ────────────────────────────── */
.save-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 20rpx 24rpx 48rpx;
background: #ffffff;
box-shadow: 0 -4rpx 20rpx rgba(0,0,0,0.08);
}
.save-btn {
width: 100%;
height: 96rpx;
border-radius: 48rpx;
background: linear-gradient(90deg, #1a1a2e, #2d2d5e);
display: flex;
align-items: center;
justify-content: center;
&--loading { opacity: 0.6; }
}
.save-btn-text { font-size: 30rpx; font-weight: 700; color: $primary-dark; }
/* ── Modal ───────────────────────────────── */
.modal-mask {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: flex-end;
z-index: 100;
}
.modal {
width: 100%;
background: #ffffff;
border-radius: 24rpx 24rpx 0 0;
padding: 40rpx 32rpx 60rpx;
}
.modal-title {
font-size: 32rpx;
font-weight: 700;
color: #1a1a2e;
display: block;
margin-bottom: 24rpx;
}
.modal-field {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 0;
border-bottom: 1rpx solid #f5f5f5;
&--last { border-bottom: none; }
}
.modal-label { font-size: 26rpx; color: #555; width: 140rpx; flex-shrink: 0; }
.modal-input { flex: 1; text-align: right; font-size: 26rpx; color: #222; }
.picker-display { display: flex; align-items: center; gap: 8rpx; }
.picker-text { font-size: 26rpx; color: #222; }
.picker-arrow { font-size: 26rpx; color: #bbb; }
.modal-actions {
display: flex;
gap: 16rpx;
margin-top: 32rpx;
}
.modal-cancel {
flex: 1;
height: 88rpx;
background: #f0f0f0;
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
}
.modal-cancel-text { font-size: 28rpx; color: #555; }
.modal-confirm {
flex: 2;
height: 88rpx;
background: linear-gradient(90deg, #1a1a2e, #2d2d5e);
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
}
.modal-confirm-text { font-size: 28rpx; font-weight: 700; color: $primary-dark; }
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -10,8 +10,8 @@
<!-- Date & period filters -->
<view class="filter-header">
<DateSelector v-model="selectedDate" @select="onDateSelect" />
<TimePeriodFilter v-model="selectedPeriod" @change="onPeriodChange" />
<DateSelector v-model="selectedDate" variant="booking" @select="onDateSelect" />
<TimePeriodFilter v-model="selectedPeriod" variant="booking" @change="onPeriodChange" />
</view>
<!-- Slot list -->
@@ -37,8 +37,10 @@
<!-- Empty state -->
<view v-else-if="filteredSlots.length === 0" class="empty-wrap">
<view class="empty-icon-circle">
<text class="empty-icon-text">📅</text>
<view class="empty-illustration">
<view class="empty-circle outer" />
<view class="empty-circle inner" />
<view class="empty-dot" />
</view>
<text class="empty-text">当日暂无可约时段</text>
<text class="empty-sub">请选择其他日期或时段查看</text>
@@ -59,6 +61,7 @@
:time-slot="item"
@book="onBookTap"
@cancel="onCancelTap"
@card-tap="onSlotCardTap"
/>
</view>
@@ -81,9 +84,10 @@
import { ref, computed, onMounted } from 'vue'
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pilates/shared'
import { TIME_PERIODS } from '@mp-pilates/shared'
import { BookingStatus, TIME_PERIODS } from '@mp-pilates/shared'
import { useBookingStore } from '../../stores/booking'
import { useUserStore } from '../../stores/user'
import { getErrorMessage } from '../../utils/auth'
import { formatDate } from '../../utils/format'
import { getSystemLayout } from '../../utils/system'
import DateSelector from '../../components/DateSelector.vue'
@@ -148,7 +152,7 @@ updateLayout()
// ─── Filtered slots ───────────────────────────────────────
const filteredSlots = computed<TimeSlotWithBookingStatus[]>(() => {
const slots = bookingStore.slots as TimeSlotWithBookingStatus[]
if (!selectedPeriod.value) return [...slots]
if (!selectedPeriod.value) return slots
const period = TIME_PERIODS[selectedPeriod.value]
return slots.filter((slot) => {
@@ -175,11 +179,36 @@ function onDateSelect(date: string) {
}
function onPeriodChange(_period: PeriodKey) {
// Filtering is done client-side via computed property
// No-op: filtering is done client-side via computed property
void _period
}
// ─── Card tap → navigate to detail ───────────────────────
function onSlotCardTap(slot: TimeSlotWithBookingStatus) {
if (slot.isBookedByMe && slot.myBookingId) {
// Already booked → show booking detail
uni.navigateTo({ url: `/pages/booking/detail?id=${slot.myBookingId}` })
} else {
// Not booked → show slot preview with booking action
uni.navigateTo({ url: `/pages/booking/detail?slotId=${slot.id}&date=${slot.date}` })
}
}
// ─── Book flow ────────────────────────────────────────────
async function onBookTap(slot: TimeSlotWithBookingStatus) {
if (slot.isBookedByMe) {
if (slot.myBookingId) {
uni.navigateTo({ url: `/pages/booking/detail?id=${slot.myBookingId}` })
return
}
const title = slot.myBookingStatus === BookingStatus.PENDING_CONFIRMATION
? '该时段已预约,等待老师确认'
: '该时段已预约'
uni.showToast({ title, icon: 'none' })
return
}
// 1. Ensure logged in
if (!userStore.loggedIn) {
uni.showModal({
@@ -189,12 +218,12 @@ async function onBookTap(slot: TimeSlotWithBookingStatus) {
success: async (res) => {
if (res.confirm) {
try {
await userStore.login()
await userStore.fetchMemberships()
// Retry booking flow after login
onBookTap(slot)
} catch {
uni.showToast({ title: '登录失败', icon: 'none' })
const { isNewUser } = await userStore.loginWithSetup()
if (!isNewUser) {
onBookTap(slot)
}
} catch (err: unknown) {
uni.showToast({ title: getErrorMessage(err, '登录失败'), icon: 'none' })
}
}
},
@@ -211,7 +240,9 @@ async function onBookTap(slot: TimeSlotWithBookingStatus) {
cancelText: '取消',
success: (res) => {
if (res.confirm) {
uni.navigateTo({ url: '/pages/store/index' })
// Switch to home tab and auto-scroll to card shop
uni.$emit('scrollToCardShop')
uni.switchTab({ url: '/pages/home/index' })
}
},
})
@@ -281,7 +312,9 @@ onMounted(async () => {
<style lang="scss" scoped>
.booking-page {
height: 100vh;
background: $primary-bg;
background:
radial-gradient(circle at top, rgba(255, 232, 218, 0.36), transparent 34%),
linear-gradient(180deg, #fbf7f3 0%, #f6efea 100%);
display: flex;
flex-direction: column;
overflow: hidden;
@@ -290,7 +323,7 @@ onMounted(async () => {
/* ── Status bar ───────────────────────────────────── */
.status-bar {
flex-shrink: 0;
background: #fff;
background: #fcfaf8;
}
/* ── Page header ──────────────────────────────────── */
@@ -300,20 +333,21 @@ onMounted(async () => {
display: flex;
align-items: center;
justify-content: center;
background: #fff;
background: #fcfaf8;
}
.page-title {
font-size: 34rpx;
font-weight: 600;
color: #1a1a2e;
font-weight: 700;
color: #3a2e2a;
letter-spacing: 1rpx;
}
/* ── Filter header ────────────────────────────────── */
.filter-header {
flex-shrink: 0;
background: #fff;
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.04);
background: rgba(252, 250, 248, 0.96);
box-shadow: 0 12rpx 30rpx rgba(120, 91, 79, 0.06);
}
/* ── Scroll container ──────────────────────────────── */
@@ -327,46 +361,46 @@ onMounted(async () => {
.slot-list {
display: flex;
flex-direction: column;
gap: 20rpx;
padding: 24rpx 24rpx 0;
padding: 28rpx 0 0;
}
/* ── Date summary ──────────────────────────────────── */
.date-summary {
padding: 0 8rpx 4rpx;
padding: 0 24rpx 16rpx;
}
.date-summary-text {
font-size: 24rpx;
color: #999;
font-weight: 400;
color: #9d8b83;
font-weight: 500;
}
/* ── Loading skeleton ──────────────────────────────── */
.loading-wrap {
display: flex;
flex-direction: column;
gap: 20rpx;
gap: 24rpx;
padding: 28rpx 24rpx;
}
.skeleton-card {
height: 140rpx;
border-radius: 24rpx;
background: #fff;
height: 220rpx;
border-radius: 26rpx;
background: rgba(255, 255, 255, 0.88);
display: flex;
flex-direction: row;
align-items: center;
padding: 32rpx 28rpx 32rpx 36rpx;
gap: 24rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.03);
padding: 28rpx 48rpx;
gap: 20rpx;
margin: 0 24rpx;
box-shadow: 0 16rpx 36rpx rgba(120, 91, 79, 0.08);
}
.skeleton-time {
width: 80rpx;
height: 72rpx;
width: 90rpx;
height: 80rpx;
border-radius: 12rpx;
background: linear-gradient(90deg, $primary-border 25%, $primary-light 50%, $primary-border 75%);
background: linear-gradient(90deg, rgba(236, 225, 217, 0.9) 25%, rgba(248, 240, 233, 0.98) 50%, rgba(236, 225, 217, 0.9) 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
flex-shrink: 0;
@@ -383,7 +417,7 @@ onMounted(async () => {
width: 60%;
height: 28rpx;
border-radius: 8rpx;
background: linear-gradient(90deg, $primary-border 25%, $primary-light 50%, $primary-border 75%);
background: linear-gradient(90deg, rgba(236, 225, 217, 0.9) 25%, rgba(248, 240, 233, 0.98) 50%, rgba(236, 225, 217, 0.9) 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
@@ -392,16 +426,16 @@ onMounted(async () => {
width: 40%;
height: 20rpx;
border-radius: 6rpx;
background: linear-gradient(90deg, $primary-border 25%, $primary-light 50%, $primary-border 75%);
background: linear-gradient(90deg, rgba(236, 225, 217, 0.9) 25%, rgba(248, 240, 233, 0.98) 50%, rgba(236, 225, 217, 0.9) 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
.skeleton-btn {
width: 140rpx;
height: 72rpx;
border-radius: 36rpx;
background: linear-gradient(90deg, $primary-border 25%, $primary-light 50%, $primary-border 75%);
width: 100rpx;
height: 60rpx;
border-radius: 20rpx;
background: linear-gradient(90deg, rgba(236, 225, 217, 0.9) 25%, rgba(248, 240, 233, 0.98) 50%, rgba(236, 225, 217, 0.9) 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
flex-shrink: 0;
@@ -413,34 +447,75 @@ onMounted(async () => {
flex-direction: column;
align-items: center;
justify-content: center;
padding: 140rpx 40rpx;
gap: 16rpx;
padding: 120rpx 40rpx 80rpx;
gap: 0;
}
.empty-icon-circle {
width: 140rpx;
height: 140rpx;
/* Zen-inspired geometric illustration */
.empty-illustration {
position: relative;
width: 200rpx;
height: 200rpx;
margin-bottom: 56rpx;
}
.empty-circle {
position: absolute;
border-radius: 50%;
background: $primary-border;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16rpx;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
&.outer {
width: 180rpx;
height: 180rpx;
border: 2rpx solid rgba(192, 154, 137, 0.18);
animation: breathe 3s ease-in-out infinite;
}
&.inner {
width: 120rpx;
height: 120rpx;
background: linear-gradient(135deg, #f6e9e1 0%, #ddc1b4 50%, #b98f7d 100%);
opacity: 0.6;
animation: breathe 3s ease-in-out infinite 0.5s;
}
}
.empty-icon-text {
font-size: 56rpx;
.empty-dot {
position: absolute;
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background: #a87d6c;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
animation: pulse 2s ease-in-out infinite;
}
.empty-text {
font-size: 30rpx;
color: #666;
font-size: 32rpx;
color: #6f605b;
font-weight: 600;
letter-spacing: 2rpx;
margin-bottom: 16rpx;
}
.empty-sub {
font-size: 26rpx;
color: #bbb;
color: #a18a82;
letter-spacing: 1rpx;
}
@keyframes breathe {
0%, 100% { transform: translate(-50%, -50%) scale(1); opacity: 0.6; }
50% { transform: translate(-50%, -50%) scale(1.05); opacity: 0.4; }
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
50% { opacity: 0.5; transform: translate(-50%, -50%) scale(0.8); }
}
/* ── Bottom spacer ─────────────────────────────────── */

View File

@@ -37,25 +37,44 @@
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>
<!-- Card Cover image if available, gradient fallback -->
<view class="card-cover" :class="c.coverUrl ? '' : getCardCoverClass(c.type)">
<image
v-if="c.coverUrl"
class="card-cover-img"
:src="c.coverUrl"
mode="aspectFill"
/>
<template v-else>
<view class="cover-deco cover-deco--1" />
<view class="cover-deco cover-deco--2" />
</template>
</view>
<!-- Card info aligns with card-cover height -->
<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>
<text
v-if="c.originalPrice && c.originalPrice > c.price"
class="price-original"
>
原价:¥{{ formatPrice(c.originalPrice) }}
</text>
<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">
@@ -66,23 +85,32 @@
<!-- Card content (single card mode) -->
<template v-else>
<!-- Hero section -->
<view class="card-hero" :class="heroClass">
<!-- Decorative circles -->
<view class="hero-deco hero-deco--1" />
<view class="hero-deco hero-deco--2" />
<view class="card-hero" :class="cardData.coverUrl ? 'hero--custom' : heroClass">
<!-- Cover image background -->
<image
v-if="cardData.coverUrl"
class="hero-cover-img"
:src="cardData.coverUrl"
mode="aspectFill"
/>
<!-- Decorative circles (only when no cover image) -->
<template v-else>
<view class="hero-deco hero-deco--1" />
<view class="hero-deco hero-deco--2" />
</template>
<view class="hero-badge">
<text class="hero-badge-text">{{ typeLabel }}</text>
</view>
<text class="hero-name">{{ card.name }}</text>
<text class="hero-name">{{ cardData.name }}</text>
<view class="hero-price-row">
<text class="hero-currency">¥</text>
<text class="hero-price">{{ formatPrice(card.price) }}</text>
<text class="hero-price">{{ formatPrice(cardData.price) }}</text>
<text
v-if="card.originalPrice && card.originalPrice > card.price"
v-if="cardData.originalPrice && cardData.originalPrice > cardData.price"
class="hero-original"
>
¥{{ formatPrice(card.originalPrice) }}
¥{{ formatPrice(cardData.originalPrice) }}
</text>
</view>
</view>
@@ -92,28 +120,28 @@
<!-- Key info grid -->
<view class="info-card">
<view class="info-grid">
<view class="info-cell" v-if="card.totalTimes">
<text class="cell-value">{{ card.totalTimes }}</text>
<view class="info-cell" v-if="cardData.totalTimes">
<text class="cell-value">{{ cardData.totalTimes }}</text>
<text class="cell-label">课时次数</text>
</view>
<view class="info-cell">
<text class="cell-value">{{ card.durationDays }}</text>
<text class="cell-value">{{ cardData.durationDays }}</text>
<text class="cell-label">有效天数</text>
</view>
<view class="info-cell">
<text class="cell-value">{{ unitPrice }}</text>
<text class="cell-label">{{ card.totalTimes ? '每次单价' : '按天均价' }}</text>
<text class="cell-label">{{ cardData.totalTimes ? '每次单价' : '按天均价' }}</text>
</view>
</view>
</view>
<!-- Description -->
<view v-if="card.description" class="desc-card">
<view v-if="cardData.description" class="desc-card">
<view class="section-header">
<view class="section-dot" />
<text class="section-title">课程说明</text>
</view>
<text class="desc-content">{{ card.description }}</text>
<text class="desc-content">{{ cardData.description }}</text>
</view>
<!-- Features list -->
@@ -124,13 +152,13 @@
</view>
<view class="feature-item">
<text class="feature-dot"></text>
<text class="feature-text">购买后立即生效有效期 {{ card.durationDays }} </text>
<text class="feature-text">购买后立即生效有效期 {{ cardData.durationDays }} </text>
</view>
<view v-if="card.totalTimes" class="feature-item">
<view v-if="cardData.totalTimes" class="feature-item">
<text class="feature-dot"></text>
<text class="feature-text"> {{ card.totalTimes }} 次课时可灵活安排上课时间</text>
<text class="feature-text"> {{ cardData.totalTimes }} 次课时可灵活安排上课时间</text>
</view>
<view v-if="!card.totalTimes" class="feature-item">
<view v-if="!cardData.totalTimes" class="feature-item">
<text class="feature-dot"></text>
<text class="feature-text">有效期内可无限次预约课程</text>
</view>
@@ -153,7 +181,7 @@
<view class="bottom-bar">
<view class="price-summary">
<text class="summary-label">实付金额</text>
<text class="summary-price">¥{{ formatPrice(card.price) }}</text>
<text class="summary-price">¥{{ formatPrice(cardData.price) }}</text>
</view>
<view
class="buy-btn"
@@ -171,10 +199,12 @@
import { ref, computed, onMounted } from 'vue'
import type { CardType, CreateOrderResponse } from '@mp-pilates/shared'
import { CardTypeCategory } from '@mp-pilates/shared'
import { getErrorMessage } from '../../utils/auth'
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 { requestOrderPaidSubscriptionMessage } from '../../utils/wechat-subscription'
import CustomNavBar from '../../components/CustomNavBar.vue'
const userStore = useUserStore()
@@ -221,6 +251,8 @@ const unitPrice = computed(() => {
return `¥${(pricePerDay / 100).toFixed(0)}`
})
const cardData = computed<CardType>(() => card.value as CardType)
// ─── Data loading ─────────────────────────────────────────
async function loadCard() {
loading.value = true
@@ -257,16 +289,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
@@ -280,10 +302,12 @@ async function handleBuy() {
success: async (res) => {
if (res.confirm) {
try {
await userStore.login()
handleBuy()
} catch {
uni.showToast({ title: '登录失败', icon: 'none' })
const { isNewUser } = await userStore.loginWithSetup()
if (!isNewUser) {
handleBuy()
}
} catch (err: unknown) {
uni.showToast({ title: getErrorMessage(err, '登录失败'), icon: 'none' })
}
}
},
@@ -308,8 +332,10 @@ async function doPurchase() {
uni.showLoading({ title: '创建订单...' })
try {
const inviterId = uni.getStorageSync('invite_inviter_id') as string
const result = await post<CreateOrderResponse>('/payment/create-order', {
cardTypeId: card.value.id,
inviterId: isTrial.value && inviterId ? inviterId : undefined,
})
uni.hideLoading()
@@ -329,6 +355,7 @@ async function doPurchase() {
})
// Payment succeeded — refresh memberships then navigate
await requestOrderPaidSubscriptionMessage().catch(() => undefined)
uni.showToast({ title: '购买成功!', icon: 'success' })
await userStore.fetchMemberships()
setTimeout(() => {
@@ -438,23 +465,35 @@ onMounted(() => {
overflow: hidden;
&.hero--times {
background: linear-gradient(135deg, #1a1a2e 0%, #2d2d5e 100%);
background: linear-gradient(135deg, #E8D5C4 0%, #D4BFA8 100%);
}
&.hero--duration {
background: linear-gradient(135deg, #6c3483 0%, #9b59b6 100%);
background: linear-gradient(135deg, #D8C8DC 0%, #C4AECB 100%);
}
&.hero--trial {
background: linear-gradient(135deg, #5a7a8a 0%, $primary-dark 100%);
background: linear-gradient(135deg, #C8D8D2 0%, #A9C4BC 100%);
}
&.hero--custom {
background: #333;
}
}
.hero-cover-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0.5;
}
/* Decorative background circles */
.hero-deco {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.05);
background: rgba(255, 255, 255, 0.35);
pointer-events: none;
&--1 {
@@ -476,14 +515,14 @@ onMounted(() => {
align-self: flex-start;
padding: 8rpx 22rpx;
border-radius: 20rpx;
background: rgba(255, 255, 255, 0.18);
border: 1rpx solid rgba(255, 255, 255, 0.3);
background: rgba(74, 64, 53, 0.1);
border: 1rpx solid rgba(74, 64, 53, 0.15);
z-index: 1;
}
.hero-badge-text {
font-size: 22rpx;
color: #fff;
color: $brand-color;
font-weight: 600;
letter-spacing: 1rpx;
}
@@ -491,7 +530,7 @@ onMounted(() => {
.hero-name {
font-size: 48rpx;
font-weight: 800;
color: #fff;
color: $brand-color;
letter-spacing: 1rpx;
z-index: 1;
}
@@ -506,20 +545,20 @@ onMounted(() => {
.hero-currency {
font-size: 28rpx;
font-weight: 600;
color: rgba(255, 255, 255, 0.85);
color: rgba(74, 64, 53, 0.7);
line-height: 1;
}
.hero-price {
font-size: 64rpx;
font-weight: 800;
color: #fff;
color: $brand-color;
line-height: 1;
}
.hero-original {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.5);
color: rgba(74, 64, 53, 0.4);
text-decoration: line-through;
margin-left: 8rpx;
}
@@ -552,7 +591,7 @@ onMounted(() => {
.section-title {
font-size: 30rpx;
font-weight: 700;
color: #1a1a1a;
color: $text-primary;
}
/* ── Info grid card ──────────────────────────────────── */
@@ -710,101 +749,150 @@ 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 {
/* ══════════════════════════════════════════════════════════
CARD COVER — Clean minimal design
══════════════════════════════════════════════════════════ */
.card-cover {
width: 200rpx;
height: 140rpx;
border-radius: 12rpx;
height: 130rpx;
border-radius: 16rpx;
overflow: hidden;
flex-shrink: 0;
position: relative;
}
.thumb-fallback {
.card-cover-img {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8rpx;
padding: 12rpx;
}
.thumb--times .thumb-fallback {
background: linear-gradient(135deg, #3a3a3a, #555);
.cover-deco {
position: absolute;
border-radius: 50%;
pointer-events: none;
&--1 {
width: 100rpx;
height: 100rpx;
top: -30rpx;
right: -20rpx;
background: rgba(255, 255, 255, 0.4);
}
&--2 {
width: 70rpx;
height: 70rpx;
bottom: -20rpx;
left: -10rpx;
background: rgba(255, 255, 255, 0.25);
}
}
.thumb--duration .thumb-fallback {
background: linear-gradient(135deg, #6c3483, #9b59b6);
.cover--times {
background: linear-gradient(135deg, #E8D5C4 0%, #D4BFA8 100%);
}
.thumb--trial .thumb-fallback {
background: linear-gradient(135deg, #5a7a8a, $primary-dark);
.cover--duration {
background: linear-gradient(135deg, #D8C8DC 0%, #C4AECB 100%);
}
.thumb-name {
font-size: 22rpx;
font-weight: 600;
color: #ffffff;
text-align: center;
line-height: 1.3;
word-break: break-all;
}
.thumb-price {
font-size: 24rpx;
font-weight: 700;
color: #ffffff;
.cover--trial {
background: linear-gradient(135deg, #C8D8D2 0%, #A9C4BC 100%);
}
/* ── 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 {
display: block;
font-size: 30rpx;
font-weight: 600;
color: #222;
margin-bottom: 8rpx;
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

@@ -0,0 +1,840 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="限时秒杀" show-back />
<!-- Loading -->
<view v-if="loading" class="loading-wrap">
<view class="skeleton-hero" />
<view class="skeleton-body">
<view class="skeleton-line w80" />
<view class="skeleton-line w60" />
<view class="skeleton-line w40" />
</view>
</view>
<!-- Error -->
<view v-else-if="!detail" class="error-wrap">
<text class="error-icon"></text>
<text class="error-text">活动信息加载失败</text>
<view class="retry-btn" @tap="loadDetail">
<text class="retry-text">点击重试</text>
</view>
</view>
<template v-else>
<!-- Hero Section -->
<view class="hero" :class="heroPhaseClass">
<!-- Decorative elements -->
<view class="hero-deco hero-deco--1" />
<view class="hero-deco hero-deco--2" />
<view class="hero-deco hero-deco--3" />
<!-- Phase badge -->
<view class="hero-phase-badge" :class="phaseBadgeClass">
<text class="hero-phase-text">{{ phaseLabel }}</text>
</view>
<!-- Title -->
<text class="hero-title">{{ detail.title }}</text>
<!-- Price row -->
<view class="hero-price-row">
<text class="hero-currency">¥</text>
<text class="hero-price">{{ formatPrice(detail.flashPrice) }}</text>
<view class="hero-original-wrap">
<text class="hero-original-label">原价</text>
<text class="hero-original">¥{{ formatPrice(detail.originalPrice) }}</text>
</view>
</view>
<!-- Discount badge -->
<view class="hero-discount-badge">
<text class="hero-discount-text">立省 ¥{{ formatPrice(detail.originalPrice - detail.flashPrice) }}</text>
</view>
<!-- Countdown -->
<view
v-if="detail.phase === FlashSalePhase.UPCOMING || detail.phase === FlashSalePhase.ONGOING"
class="hero-countdown"
>
<text class="cd-label">
{{ detail.phase === FlashSalePhase.UPCOMING ? '距开始' : '距结束' }}
</text>
<view class="cd-blocks">
<text class="cd-block">{{ countdown.h }}</text>
<text class="cd-colon">:</text>
<text class="cd-block">{{ countdown.m }}</text>
<text class="cd-colon">:</text>
<text class="cd-block">{{ countdown.s }}</text>
</view>
</view>
</view>
<!-- Stock Bar -->
<view class="stock-section">
<view class="stock-info">
<text class="stock-label">抢购进度</text>
<text class="stock-count">
{{ detail.phase === FlashSalePhase.SOLD_OUT ? '已售罄' : `已抢 ${detail.soldCount}/${detail.totalStock}` }}
</text>
</view>
<view class="stock-bar">
<view
class="stock-fill"
:class="{ 'stock-fill--hot': stockRatio > 0.6 }"
:style="{ width: stockPercent }"
/>
</view>
</view>
<!-- Phone Auth Prompt -->
<view
v-if="userStore.loggedIn && !userStore.user?.phone"
class="phone-prompt-card"
>
<view class="phone-prompt-content">
<view class="phone-prompt-icon">📱</view>
<view class="phone-prompt-text">
<text class="phone-prompt-title">提前授权手机号</text>
<text class="phone-prompt-desc">授权后抢购更快也方便馆主联系您</text>
</view>
</view>
<button
class="phone-auth-btn"
open-type="getPhoneNumber"
@getphonenumber="handleGetPhone"
>
<text class="phone-auth-text">立即授权</text>
</button>
</view>
<!-- Card Info -->
<view class="detail-section">
<view class="info-card">
<view class="section-header-row">
<view class="section-dot" />
<text class="section-label">会员卡信息</text>
</view>
<view class="info-grid">
<view class="info-cell">
<text class="cell-value">{{ detail.cardType.name }}</text>
<text class="cell-label">卡种</text>
</view>
<view v-if="detail.cardType.totalTimes" class="info-cell">
<text class="cell-value">{{ detail.cardType.totalTimes }}</text>
<text class="cell-label">课时次数</text>
</view>
<view class="info-cell">
<text class="cell-value">{{ detail.cardType.durationDays }}</text>
<text class="cell-label">有效天数</text>
</view>
</view>
</view>
<!-- Description -->
<view v-if="detail.description" class="desc-card">
<view class="section-header-row">
<view class="section-dot" />
<text class="section-label">活动说明</text>
</view>
<text class="desc-content">{{ detail.description }}</text>
</view>
<!-- Purchase Notes -->
<view class="notes-card">
<view class="section-header-row">
<view class="section-dot" />
<text class="section-label">参与须知</text>
</view>
<view class="note-item">
<text class="note-dot"></text>
<text class="note-text">每位用户同一秒杀活动仅限参与一次</text>
</view>
<view class="note-item">
<text class="note-dot"></text>
<text class="note-text">购买后立即生效有效期 {{ detail.cardType.durationDays }} </text>
</view>
<view v-if="detail.cardType.totalTimes" class="note-item">
<text class="note-dot"></text>
<text class="note-text"> {{ detail.cardType.totalTimes }} 次课时可灵活预约</text>
</view>
<view class="note-item">
<text class="note-dot"></text>
<text class="note-text">需登录并授权手机号后方可参与秒杀</text>
</view>
<view class="note-item">
<text class="note-dot"></text>
<text class="note-text">建议提前完善账号信息及手机号授权方便馆主联系</text>
</view>
<view class="note-item">
<text class="note-dot"></text>
<text class="note-text">秒杀卡不可退款到期或课时用完后自动失效</text>
</view>
<view class="note-item">
<text class="note-dot"></text>
<text class="note-text">支持微信支付安全便捷</text>
</view>
<view class="note-item note-item--disclaimer">
<text class="note-text disclaimer-text">* 本活动最终解释权归普拉提馆所有</text>
</view>
</view>
</view>
<!-- Bottom Action Bar -->
<view class="bottom-bar">
<view class="bar-price-area">
<text class="bar-price-label">秒杀价</text>
<view class="bar-price-row">
<text class="bar-currency">¥</text>
<text class="bar-price">{{ formatPrice(detail.flashPrice) }}</text>
</view>
</view>
<view
class="action-btn"
:class="actionBtnClass"
@tap="handleAction"
>
<text class="action-btn-text">{{ actionBtnText }}</text>
</view>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import {
FlashSalePhase,
FlashSaleOrderStatus,
} from '@mp-pilates/shared'
import type { FlashSaleDetail } from '@mp-pilates/shared'
import { getErrorMessage } from '../../utils/auth'
import { formatPrice, getFlashSalePhaseLabel, getCountdownParts, getStockRatio, getStockPercent } from '../../utils/format'
import { getSystemLayout } from '../../utils/system'
import { useUserStore } from '../../stores/user'
import { useFlashSaleStore } from '../../stores/flash-sale'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { post } from '../../utils/request'
import { requestOrderPaidSubscriptionMessage } from '../../utils/wechat-subscription'
const userStore = useUserStore()
const flashSaleStore = useFlashSaleStore()
const navBarHeight = ref('64px')
const loading = ref(false)
const buying = ref(false)
const detail = ref<FlashSaleDetail | null>(null)
const flashSaleId = ref('')
const tick = ref(0)
let timer: ReturnType<typeof setInterval> | null = null
// ─── Computed ─────────────────────────────────────────
const phaseLabel = computed(() => {
if (!detail.value) return ''
return getFlashSalePhaseLabel(detail.value.phase)
})
const heroPhaseClass = computed(() => {
if (!detail.value) return ''
if (detail.value.phase === FlashSalePhase.ONGOING) return 'hero--ongoing'
if (detail.value.phase === FlashSalePhase.UPCOMING) return 'hero--upcoming'
return 'hero--inactive'
})
const phaseBadgeClass = computed(() => {
if (!detail.value) return ''
if (detail.value.phase === FlashSalePhase.ONGOING) return 'pbadge--ongoing'
if (detail.value.phase === FlashSalePhase.UPCOMING) return 'pbadge--upcoming'
return 'pbadge--inactive'
})
const stockRatio = computed(() => {
if (!detail.value) return 0
return getStockRatio(detail.value.soldCount, detail.value.totalStock)
})
const stockPercent = computed(() => {
if (!detail.value) return '0%'
return getStockPercent(detail.value.soldCount, detail.value.totalStock)
})
const countdown = computed(() => {
void tick.value
if (!detail.value) return { h: '00', m: '00', s: '00' }
const target = detail.value.phase === FlashSalePhase.UPCOMING
? detail.value.startTime
: detail.value.endTime
return getCountdownParts(target)
})
const isDisabled = computed(() => {
if (!detail.value) return true
const d = detail.value
if (d.hasParticipated) return true
if (d.phase === FlashSalePhase.SOLD_OUT) return true
if (d.phase === FlashSalePhase.ENDED) return true
if (d.phase === FlashSalePhase.UPCOMING) return true
if (buying.value) return true
return false
})
const actionBtnText = computed(() => {
if (!detail.value) return ''
const d = detail.value
if (d.hasParticipated) {
if (d.userOrderStatus === FlashSaleOrderStatus.PAID) return '已成功抢购'
if (d.userOrderStatus === FlashSaleOrderStatus.RESERVED) return '待支付'
return '已参与'
}
if (d.phase === FlashSalePhase.SOLD_OUT) return '已售罄'
if (d.phase === FlashSalePhase.ENDED) return '活动已结束'
if (d.phase === FlashSalePhase.UPCOMING) return `距开始 ${countdown.value.h}:${countdown.value.m}:${countdown.value.s}`
if (!userStore.loggedIn) return '登录后参与'
if (!userStore.user?.phone) return '授权手机号后参与'
if (buying.value) return '抢购中...'
return `¥${formatPrice(d.flashPrice)} 立即抢购`
})
const actionBtnClass = computed(() => {
if (isDisabled.value) return 'action-btn--disabled'
return 'action-btn--active'
})
// ─── Data loading ────────────────────────────────────
async function loadDetail() {
if (!flashSaleId.value) return
loading.value = true
try {
detail.value = await flashSaleStore.fetchDetail(flashSaleId.value)
} catch {
detail.value = null
} finally {
loading.value = false
}
}
// ─── Phone auth ──────────────────────────────────────
async function handleGetPhone(e: { detail: { code?: string; errMsg?: string } }) {
if (!e.detail.code) return
try {
await post('/auth/phone', { code: e.detail.code })
await userStore.fetchProfile()
uni.showToast({ title: '授权成功', icon: 'success' })
} catch {
uni.showToast({ title: '授权失败,请重试', icon: 'none' })
}
}
// ─── Action handler ──────────────────────────────────
async function handleAction() {
if (!detail.value || isDisabled.value) return
// Check login
if (!userStore.loggedIn) {
uni.showModal({
title: '提示',
content: '请先登录后再参与秒杀',
confirmText: '去登录',
success: async (res) => {
if (res.confirm) {
try {
const { isNewUser } = await userStore.loginWithSetup()
if (!isNewUser) {
await loadDetail() // refresh participation status
}
} catch (err: unknown) {
uni.showToast({ title: getErrorMessage(err, '登录失败'), icon: 'none' })
}
}
},
})
return
}
// Check phone
if (!userStore.user?.phone) {
uni.showToast({ title: '请先授权手机号', icon: 'none' })
return
}
// Confirm purchase
uni.showModal({
title: '确认抢购',
content: `确认以 ¥${formatPrice(detail.value.flashPrice)} 抢购「${detail.value.title}」?`,
confirmText: '确认抢购',
success: async (res) => {
if (res.confirm) {
await doPurchase()
}
},
})
}
async function doPurchase() {
if (!detail.value || buying.value) return
buying.value = true
uni.showLoading({ title: '抢购中...' })
try {
const result = await flashSaleStore.purchase(detail.value.id)
uni.hideLoading()
// Launch WeChat Pay
await new Promise<void>((resolve, reject) => {
uni.requestPayment({
provider: 'wxpay',
timeStamp: result.paymentParams.timeStamp,
nonceStr: result.paymentParams.nonceStr,
package: result.paymentParams.package,
signType: result.paymentParams.signType as 'MD5' | 'HMAC-SHA256',
paySign: result.paymentParams.paySign,
success: () => resolve(),
fail: (err: { errMsg?: string }) => reject(new Error(err.errMsg ?? '支付取消')),
})
})
await requestOrderPaidSubscriptionMessage().catch(() => undefined)
uni.showToast({ title: '抢购成功!', icon: 'success' })
await userStore.fetchMemberships()
await loadDetail() // refresh status
setTimeout(() => {
uni.navigateTo({ url: '/pages/profile/membership' })
}, 1500)
} catch (err: unknown) {
uni.hideLoading()
const msg = err instanceof Error ? err.message : '抢购失败'
if (!msg.includes('取消') && !msg.includes('cancel')) {
uni.showToast({ title: msg, icon: 'none', duration: 3000 })
}
// Refresh detail to show updated status
await loadDetail()
} finally {
buying.value = false
}
}
// ─── Lifecycle ───────────────────────────────────────
onMounted(() => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
const pages = getCurrentPages()
const current = pages[pages.length - 1]
const options = (current as { options?: Record<string, string> }).options ?? {}
flashSaleId.value = options.id ?? ''
loadDetail()
timer = setInterval(() => { tick.value++ }, 1000)
})
onUnmounted(() => {
if (timer) {
clearInterval(timer)
timer = null
}
})
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: $bg-page;
padding-bottom: calc(180rpx + env(safe-area-inset-bottom));
}
/* ── Loading ────────────────────────────── */
.loading-wrap { padding: 0; }
.skeleton-hero {
height: 420rpx;
background: linear-gradient(90deg, #ede8e3 25%, #e4dfd9 50%, #ede8e3 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
.skeleton-body { padding: 32rpx 24rpx; display: flex; flex-direction: column; gap: 20rpx; }
.skeleton-line {
height: 28rpx;
border-radius: 14rpx;
background: linear-gradient(90deg, #f0ece8 25%, #e8e4df 50%, #f0ece8 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
&.w80 { width: 80%; }
&.w60 { width: 60%; }
&.w40 { width: 40%; }
}
/* ── Error ───────────────────────────────── */
.error-wrap {
display: flex;
flex-direction: column;
align-items: center;
padding: 160rpx 40rpx;
gap: 24rpx;
}
.error-icon { font-size: 80rpx; }
.error-text { font-size: 30rpx; color: $text-hint; }
.retry-btn {
padding: 20rpx 48rpx;
border-radius: 40rpx;
background: linear-gradient(135deg, #D4A59A, #C08B7E);
}
.retry-text { font-size: 28rpx; color: #fff; font-weight: 600; }
/* ═══════════════════════════════════════════
HERO — warm blush tones
═══════════════════════════════════════════ */
.hero {
padding: 56rpx 36rpx 48rpx;
display: flex;
flex-direction: column;
gap: 16rpx;
position: relative;
overflow: hidden;
}
.hero--ongoing {
background: linear-gradient(135deg, #D4A59A 0%, #C9948A 35%, #B5836E 100%);
}
.hero--upcoming {
background: linear-gradient(135deg, #8FA89A 0%, #7BA5A0 100%);
}
.hero--inactive {
background: linear-gradient(135deg, #C4BAB0 0%, #AEA49A 100%);
}
.hero-deco {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
pointer-events: none;
&--1 { width: 300rpx; height: 300rpx; top: -60rpx; right: -40rpx; }
&--2 { width: 200rpx; height: 200rpx; bottom: -60rpx; left: 30rpx; }
&--3 { width: 120rpx; height: 120rpx; top: 40rpx; left: -30rpx; background: rgba(255, 255, 255, 0.05); }
}
.hero-phase-badge {
align-self: flex-start;
padding: 8rpx 24rpx;
border-radius: 24rpx;
z-index: 1;
}
.pbadge--ongoing { background: rgba(255, 255, 255, 0.3); }
.pbadge--upcoming { background: rgba(255, 255, 255, 0.25); }
.pbadge--inactive { background: rgba(0, 0, 0, 0.12); }
.hero-phase-text { font-size: 24rpx; color: #fff; font-weight: 600; letter-spacing: 1rpx; }
.hero-title {
font-size: 44rpx;
font-weight: 800;
color: #fff;
z-index: 1;
line-height: 1.2;
}
.hero-price-row {
display: flex;
align-items: baseline;
gap: 4rpx;
z-index: 1;
}
.hero-currency { font-size: 30rpx; font-weight: 700; color: rgba(255, 255, 255, 0.9); }
.hero-price { font-size: 72rpx; font-weight: 800; color: #fff; line-height: 1; }
.hero-original-wrap {
display: flex;
flex-direction: column;
margin-left: 16rpx;
}
.hero-original-label { font-size: 18rpx; color: rgba(255, 255, 255, 0.65); }
.hero-original { font-size: 26rpx; color: rgba(255, 255, 255, 0.55); text-decoration: line-through; }
.hero-discount-badge {
align-self: flex-start;
padding: 6rpx 20rpx;
border-radius: 16rpx;
background: rgba(255, 255, 255, 0.22);
border: 1rpx solid rgba(255, 255, 255, 0.35);
z-index: 1;
}
.hero-discount-text { font-size: 22rpx; color: #fff; font-weight: 600; }
/* Countdown */
.hero-countdown {
display: flex;
align-items: center;
gap: 12rpx;
margin-top: 8rpx;
z-index: 1;
}
.cd-label { font-size: 24rpx; color: rgba(255, 255, 255, 0.85); }
.cd-blocks { display: flex; align-items: center; gap: 6rpx; }
.cd-block {
background: rgba(255, 255, 255, 0.25);
color: #fff;
font-size: 28rpx;
font-weight: 700;
padding: 8rpx 14rpx;
border-radius: 8rpx;
font-family: 'DIN Alternate', monospace;
min-width: 48rpx;
text-align: center;
backdrop-filter: blur(4px);
}
.cd-colon { color: #fff; font-size: 28rpx; font-weight: 700; }
/* ═══════════════════════════════════════════
STOCK
═══════════════════════════════════════════ */
.stock-section {
margin: 0 24rpx;
background: #fff;
border-radius: 20rpx;
padding: 24rpx;
margin-top: -20rpx;
position: relative;
z-index: 2;
box-shadow: 0 4rpx 20rpx rgba(180, 160, 130, 0.1);
}
.stock-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12rpx;
}
.stock-label { font-size: 26rpx; color: $text-secondary; font-weight: 600; }
.stock-count { font-size: 24rpx; color: #B5725E; font-weight: 600; }
.stock-bar {
height: 16rpx;
background: #f5f0ed;
border-radius: 8rpx;
overflow: hidden;
}
.stock-fill {
height: 100%;
background: linear-gradient(90deg, #D4A59A, #C08B7E);
border-radius: 8rpx;
transition: width 0.3s;
&--hot { animation: stockPulse 2s ease infinite; }
}
@keyframes stockPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
/* ═══════════════════════════════════════════
PHONE PROMPT
═══════════════════════════════════════════ */
.phone-prompt-card {
margin: 20rpx 24rpx 0;
background: linear-gradient(135deg, #FBF5F3, #F5ECEA);
border-radius: 20rpx;
padding: 24rpx;
display: flex;
align-items: center;
justify-content: space-between;
border: 1rpx solid rgba(192, 139, 126, 0.2);
}
.phone-prompt-content {
display: flex;
align-items: center;
gap: 16rpx;
flex: 1;
}
.phone-prompt-icon { font-size: 40rpx; }
.phone-prompt-text {
display: flex;
flex-direction: column;
gap: 4rpx;
}
.phone-prompt-title { font-size: 26rpx; font-weight: 700; color: #B5725E; }
.phone-prompt-desc { font-size: 22rpx; color: $text-hint; }
.phone-auth-btn {
background: linear-gradient(135deg, #D4A59A, #C08B7E) !important;
border-radius: 32rpx !important;
padding: 12rpx 28rpx !important;
border: none !important;
line-height: 1.4 !important;
font-size: 24rpx !important;
margin: 0 !important;
flex-shrink: 0;
&::after { border: none; }
}
.phone-auth-text { font-size: 24rpx; color: #fff; font-weight: 600; }
/* ═══════════════════════════════════════════
DETAIL SECTION
═══════════════════════════════════════════ */
.detail-section {
padding: 20rpx 24rpx 0;
display: flex;
flex-direction: column;
gap: 20rpx;
}
.section-header-row {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 16rpx;
}
.section-dot {
width: 6rpx;
height: 28rpx;
border-radius: 3rpx;
background: #C08B7E;
flex-shrink: 0;
}
.section-label { font-size: 30rpx; font-weight: 700; color: $text-primary; }
/* Info card */
.info-card {
background: #fff;
border-radius: 20rpx;
padding: 28rpx 24rpx;
box-shadow: 0 2rpx 12rpx rgba(180, 160, 130, 0.08);
}
.info-grid {
display: flex;
justify-content: space-around;
align-items: center;
}
.info-cell {
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
flex: 1;
position: relative;
& + & { border-left: 1rpx solid #f0ece8; }
}
.cell-value { font-size: 36rpx; font-weight: 800; color: $text-primary; line-height: 1.1; }
.cell-label { font-size: 22rpx; color: $text-hint; }
/* Description */
.desc-card {
background: #fff;
border-radius: 20rpx;
padding: 28rpx 24rpx;
box-shadow: 0 2rpx 12rpx rgba(180, 160, 130, 0.08);
}
.desc-content { font-size: 27rpx; color: $text-secondary; line-height: 1.75; }
/* Notes */
.notes-card {
background: #fff;
border-radius: 20rpx;
padding: 28rpx 24rpx;
box-shadow: 0 2rpx 12rpx rgba(180, 160, 130, 0.08);
}
.note-item {
display: flex;
align-items: flex-start;
gap: 12rpx;
padding: 6rpx 0;
}
.note-dot { font-size: 26rpx; color: #C08B7E; line-height: 1.65; flex-shrink: 0; }
.note-text { font-size: 26rpx; color: $text-secondary; line-height: 1.65; }
.note-item--disclaimer { margin-top: 12rpx; padding-top: 16rpx; border-top: 1rpx solid #f0ece8; }
.disclaimer-text { color: #bbb; font-size: 22rpx; }
/* ═══════════════════════════════════════════
BOTTOM BAR
═══════════════════════════════════════════ */
.bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #fff;
border-top: 1rpx solid #f0ece8;
padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom));
display: flex;
align-items: center;
gap: 24rpx;
box-shadow: 0 -4rpx 20rpx rgba(180, 160, 130, 0.08);
z-index: 100;
}
.bar-price-area {
display: flex;
flex-direction: column;
gap: 2rpx;
}
.bar-price-label { font-size: 20rpx; color: $text-hint; }
.bar-price-row { display: flex; align-items: baseline; }
.bar-currency { font-size: 24rpx; font-weight: 700; color: #B5725E; }
.bar-price { font-size: 44rpx; font-weight: 800; color: #B5725E; line-height: 1; }
.action-btn {
flex: 1;
height: 88rpx;
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
}
.action-btn--active {
background: linear-gradient(90deg, #D4A59A, #B5836E);
box-shadow: 0 4rpx 16rpx rgba(192, 139, 126, 0.35);
&:active { opacity: 0.85; }
}
.action-btn--disabled {
background: #d0cac4;
}
.action-btn-text {
font-size: 30rpx;
font-weight: 700;
color: #fff;
letter-spacing: 1rpx;
}
</style>

View File

@@ -1,58 +1,73 @@
<template>
<view class="home-page" :style="pageStyle">
<!-- Custom nav bar -->
<CustomNavBar title="场馆首页" />
<view class="home-page">
<!-- Brand Banner fixed background layer -->
<view class="banner-fixed">
<BrandBanner :studio-info="studioStore.studioInfo" />
</view>
<!-- Pull-to-refresh wrapper -->
<!-- Pull-to-refresh wrapper scrollable foreground -->
<scroll-view
class="page-scroll"
scroll-y
:scroll-top="scrollTop"
:scroll-with-animation="true"
:refresher-enabled="true"
:refresher-triggered="refreshing"
@refresherrefresh="handleRefresh"
@refresherrestore="refreshing = false"
>
<!-- Brand Banner (hero with bg image + centered logo) -->
<BrandBanner :studio-info="studioStore.studioInfo" />
<!-- Transparent spacer to reveal Banner behind -->
<view class="banner-spacer" />
<!-- Studio Info (photo strip + address/phone) -->
<StudioInfo :studio-info="studioStore.studioInfo" />
<!-- Floating card with rounded top corners -->
<view class="floating-card">
<!-- Drag indicator -->
<view class="card-handle">
<view class="card-handle-bar" />
</view>
<!-- Divider -->
<view class="section-divider" />
<!-- Studio Info (photo strip + address/phone) -->
<StudioInfo :studio-info="studioStore.studioInfo" />
<!-- Quick Entry (login / trial / book / renew) -->
<QuickEntry @scroll-to-card-shop="scrollToCardShop" />
<!-- Quick Entry (login / trial / book / renew) -->
<QuickEntry @scroll-to-card-shop="scrollToCardShop" />
<!-- Upcoming Bookings -->
<UpcomingBooking />
<!-- Upcoming Bookings -->
<UpcomingBooking />
<!-- Card Shop (vertical list) -->
<view :id="cardShopAnchorId">
<CardShop ref="cardShopRef" />
<!-- .5 Flash Sale Section -->
<FlashSaleSection ref="flashSaleRef" />
<!-- Card Shop (vertical list) -->
<view :id="cardShopAnchorId">
<CardShop ref="cardShopRef" />
</view>
<!-- About (teacher + studio gallery) -->
<AboutSection />
<!-- Bottom padding for tab bar -->
<view class="bottom-padding" />
</view>
<!-- Bottom padding for tab bar -->
<view class="bottom-padding" />
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, nextTick, onUnmounted } 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'
import UpcomingBooking from '../../components/UpcomingBooking.vue'
import FlashSaleSection from '../../components/FlashSaleSection.vue'
import CardShop from '../../components/CardShop.vue'
import AboutSection from '../../components/AboutSection.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,28 +90,32 @@ 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 flashSaleRef = ref<InstanceType<typeof FlashSaleSection> | null>(null)
const cardShopAnchorId = 'card-shop-anchor'
const scrollTop = ref(0)
const pendingScrollToCardShop = ref(false)
// Listen for cross-page scroll request (e.g. from booking page "去购买")
uni.$on('scrollToCardShop', () => {
pendingScrollToCardShop.value = true
})
onUnmounted(() => {
uni.$off('scrollToCardShop')
})
// Refresh all data on every show
onShow(async () => {
await refreshData()
// If another page requested scroll to card shop, execute after data is ready
if (pendingScrollToCardShop.value) {
pendingScrollToCardShop.value = false
await nextTick()
scrollToCardShop()
}
})
async function refreshData() {
@@ -104,6 +123,7 @@ async function refreshData() {
if (userStore.loggedIn) {
tasks.push(
userStore.fetchProfile(),
userStore.fetchMemberships(),
bookingStore.fetchUpcomingBookings(),
)
@@ -111,8 +131,9 @@ async function refreshData() {
await Promise.allSettled(tasks)
// Also refresh card shop
// Also refresh card shop and flash sales
cardShopRef.value?.fetchCardTypes()
flashSaleRef.value?.fetchFlashSales()
}
async function handleRefresh() {
@@ -125,27 +146,74 @@ async function handleRefresh() {
}
function scrollToCardShop() {
uni.pageScrollTo({
selector: `#${cardShopAnchorId}`,
duration: 300,
// Reset first so setting the same value still triggers scroll
scrollTop.value = 0
nextTick(() => {
uni.createSelectorQuery()
.select(`#${cardShopAnchorId}`)
.boundingClientRect()
.selectViewport()
.scrollOffset((res) => {
if (res) {
scrollTop.value = (res as UniApp.NodeInfo).scrollTop ?? 0
}
})
.exec()
})
}
</script>
<style lang="scss" scoped>
.home-page {
min-height: 100vh;
position: relative;
height: 100vh;
background: #FAF8F5;
padding-top: var(--nav-bar-height);
}
/* Banner fixed behind everything */
.banner-fixed {
position: fixed;
top: 0;
left: 0;
width: 100%;
z-index: 0;
}
/* Scroll layer sits above banner */
.page-scroll {
height: calc(100vh - var(--nav-bar-height));
position: relative;
z-index: 1;
flex: 1;
height: 100vh;
}
.section-divider {
height: 16rpx;
/* Transparent spacer lets banner peek through */
.banner-spacer {
height: 420rpx;
}
/* Floating card that overlaps the banner */
.floating-card {
position: relative;
background: #FAF8F5;
border-radius: 32rpx 32rpx 0 0;
min-height: 100vh;
padding-top: 12rpx;
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.06);
}
/* Small drag indicator at top of card */
.card-handle {
display: flex;
justify-content: center;
padding: 16rpx 0 8rpx;
}
.card-handle-bar {
width: 64rpx;
height: 8rpx;
border-radius: 4rpx;
background: rgba(0, 0, 0, 0.1);
}
.bottom-padding {

View File

@@ -41,7 +41,9 @@
<!-- Empty -->
<view v-else-if="upcomingBookings.length === 0" class="empty-wrap">
<view class="empty-illustration">
<text class="empty-icon">&#x1F9D8;</text>
<view class="empty-circle outer" />
<view class="empty-circle inner" />
<view class="empty-dot" />
</view>
<text class="empty-title">暂无即将上课的预约</text>
<text class="empty-sub">开始预约你的普拉提课程吧</text>
@@ -117,7 +119,9 @@
<!-- Empty -->
<view v-else-if="historyBookings.length === 0" class="empty-wrap">
<view class="empty-illustration">
<text class="empty-icon">&#x1F4CB;</text>
<view class="empty-circle outer" />
<view class="empty-circle inner" />
<view class="empty-dot" />
</view>
<text class="empty-title">暂无历史记录</text>
<text class="empty-sub">已完成或取消的课程将显示在这里</text>
@@ -481,33 +485,73 @@ onMounted(() => {
align-items: center;
justify-content: center;
padding: 120rpx 40rpx;
gap: 16rpx;
gap: 0;
}
.empty-illustration {
width: 160rpx;
height: 160rpx;
border-radius: 80rpx;
background: #faf6f1;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16rpx;
position: relative;
width: 200rpx;
height: 200rpx;
margin-bottom: 56rpx;
}
.empty-icon {
font-size: 72rpx;
.empty-circle {
position: absolute;
border-radius: 50%;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
&.outer {
width: 180rpx;
height: 180rpx;
border: 2rpx solid $primary-border;
animation: breathe 3s ease-in-out infinite;
}
&.inner {
width: 120rpx;
height: 120rpx;
background: linear-gradient(135deg, $primary-light 0%, $primary-color 50%, $primary-dark 100%);
opacity: 0.6;
animation: breathe 3s ease-in-out infinite 0.5s;
}
}
.empty-dot {
position: absolute;
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background: $primary-dark;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
animation: pulse 2s ease-in-out infinite;
}
.empty-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
color: $primary-dark;
letter-spacing: 2rpx;
margin-bottom: 16rpx;
}
.empty-sub {
font-size: 26rpx;
color: #999;
color: $primary-color;
letter-spacing: 1rpx;
}
@keyframes breathe {
0%, 100% { transform: translate(-50%, -50%) scale(1); opacity: 0.6; }
50% { transform: translate(-50%, -50%) scale(1.05); opacity: 0.4; }
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
50% { opacity: 0.5; transform: translate(-50%, -50%) scale(0.8); }
}
.empty-btn {

View File

@@ -4,23 +4,17 @@
<CustomNavBar title="我的" transparent />
<!-- User card -->
<UserCard
:logged-in="loggedIn"
:has-profile="hasProfile"
:user="user"
:stats="stats"
:memberships="memberships"
:loading="loginLoading"
:nav-bar-height="navBarHeight"
@login="handleLogin"
/>
<UserCard :logged-in="loggedIn" :has-profile="hasProfile" :user="user" :stats="stats" :memberships="memberships"
:loading="loginLoading" :nav-bar-height="navBarHeight" @login="handleLogin" />
<!-- Menu section: always visible -->
<ProfileMenu
:is-admin="isAdmin"
:require-auth="loggedIn"
:active-membership-count="activeMembershipCount"
:upcoming-booking-count="upcomingBookingCount"
:invite-share-eligible="!!user?.inviteShareEligible"
@clear-cache="handleClearCache"
@about="handleAbout"
@require-login="handleLogin"
/>
@@ -32,21 +26,33 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { storeToRefs } from 'pinia'
import { useUserStore } from '../../stores/user'
import { useBookingStore } from '../../stores/booking'
import { getSystemLayout } from '../../utils/system'
import { getErrorMessage } from '../../utils/auth'
import UserCard from '../../components/UserCard.vue'
import ProfileMenu from '../../components/ProfileMenu.vue'
import CustomNavBar from '../../components/CustomNavBar.vue'
const userStore = useUserStore()
const bookingStore = useBookingStore()
const { loggedIn, hasProfile, user, stats, memberships, isAdmin } = storeToRefs(userStore)
const { upcomingBookings } = storeToRefs(bookingStore)
const loginLoading = ref(false)
const navBarHeight = ref(64)
const activeMembershipCount = computed(
() => user.value?.activeMembershipCount ?? userStore.activeMemberships.length,
)
const upcomingBookingCount = computed(
() => (loggedIn.value ? upcomingBookings.value.length : 0),
)
// ─── 微信分享 ───────────────────────────────────────────────
onShareAppMessage(() => {
return {
@@ -73,6 +79,7 @@ onShow(async () => {
userStore.fetchProfile(),
userStore.fetchStats(),
userStore.fetchMemberships(),
bookingStore.fetchUpcomingBookings(),
])
}
})
@@ -81,14 +88,15 @@ async function handleLogin() {
if (loginLoading.value) return
loginLoading.value = true
try {
await userStore.login()
await Promise.all([
userStore.fetchProfile(),
userStore.fetchStats(),
userStore.fetchMemberships(),
])
} catch {
uni.showToast({ title: '登录失败,请重试', icon: 'none' })
const { isNewUser } = await userStore.loginWithSetup()
if (!isNewUser) {
await Promise.all([
userStore.fetchStats(),
bookingStore.fetchUpcomingBookings(),
])
}
} catch (err: unknown) {
uni.showToast({ title: getErrorMessage(err, '登录失败,请重试'), icon: 'none' })
} finally {
loginLoading.value = false
}
@@ -121,19 +129,11 @@ function handleClearCache() {
})
}
function handleAbout() {
uni.showModal({
title: '关于我们',
content: 'Focus Core 普拉提工作室\n版本 1.0.0\n\n专注核心遇见更好的自己',
showCancel: false,
})
}
</script>
<style lang="scss" scoped>
.profile-page {
min-height: 100vh;
background: $bg-page;
&__logout-wrap {
margin: $spacing-xl $spacing-lg $spacing-xl;

View File

@@ -1,8 +1,22 @@
<template>
<view class="info-page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="个人信息" show-back />
<CustomNavBar
:title="isFromLogin ? '完善个人信息' : '个人信息'"
:show-back="!isFromLogin"
/>
<!-- First-login welcome banner -->
<view v-if="isFromLogin" class="welcome-banner">
<view class="welcome-content">
<view class="welcome-text">
<text class="welcome-title">欢迎加入</text>
<text class="welcome-desc">设置你的头像和昵称让大家认识你</text>
</view>
</view>
</view>
<!-- Avatar section -->
<view class="avatar-section">
<view class="avatar-section" :class="{ 'avatar-section--welcome': isFromLogin }">
<button class="avatar-btn" open-type="chooseAvatar" @chooseavatar="handleChooseAvatar">
<view class="avatar-wrap">
<image
@@ -14,10 +28,14 @@
<view v-else class="avatar-placeholder">
<text class="avatar-placeholder-text">{{ nicknameInitial }}</text>
</view>
<!-- Upload hint overlay -->
<view class="avatar-overlay">
<text class="avatar-overlay-text">点击更换</text>
</view>
</view>
</button>
<text class="avatar-name">{{ form.nickname || '未设置昵称' }}</text>
<text class="avatar-hint">微信头像</text>
<text class="avatar-hint">点击头像选择微信头像</text>
</view>
<!-- Form fields -->
@@ -37,8 +55,8 @@
<text class="form-arrow"></text>
</view>
<!-- Phone -->
<view class="form-row form-row--last">
<!-- Phone (hide in first-login mode) -->
<view v-if="!isFromLogin" class="form-row form-row--last">
<text class="form-label">手机号</text>
<!-- Phone set: display masked -->
@@ -56,8 +74,8 @@
</view>
</view>
<!-- Read-only info card -->
<view class="info-card">
<!-- Read-only info card (hide in first-login mode) -->
<view v-if="!isFromLogin" class="info-card">
<view class="info-row">
<text class="info-label">注册时间</text>
<text class="info-value">{{ joinDateDisplay }}</text>
@@ -72,24 +90,40 @@
<view class="save-wrap">
<view
class="save-btn"
:class="{ 'save-btn--loading': saving, 'save-btn--disabled': !isDirty || saving }"
:class="{
'save-btn--loading': saving,
'save-btn--disabled': !isFromLogin && (!isDirty || saving),
}"
@tap="handleSave"
>
<text class="save-btn-text">{{ saving ? '保存中...' : '保存修改' }}</text>
<text class="save-btn-text">
{{ saving ? '保存中...' : isFromLogin ? '保存并进入' : '保存修改' }}
</text>
</view>
<text v-if="isFromLogin" class="skip-text" @tap="handleSkip">稍后再说</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useUserStore } from '../../stores/user'
import { wxBindPhone } from '../../utils/auth'
import { getSystemLayout } from '../../utils/system'
import CustomNavBar from '../../components/CustomNavBar.vue'
const TOAST_DISPLAY_MS = 1200
const userStore = useUserStore()
// ─── Route params ────────────────────────────────────────
const isFromLogin = ref(false)
onLoad((query) => {
isFromLogin.value = query?.from === 'login'
})
// ─── Nav bar height ──────────────────────────────────────
const navBarHeight = ref('64px')
@@ -189,7 +223,10 @@ async function handleGetPhone(e: {
// ─── Save ─────────────────────────────────────────────────
async function handleSave() {
if (!isDirty.value || saving.value) return
if (saving.value) return
// In first-login mode, allow saving even if not dirty (user may just want to proceed)
if (!isFromLogin.value && !isDirty.value) return
const nickname = form.value.nickname.trim()
if (!nickname) {
@@ -206,7 +243,15 @@ async function handleSave() {
await userStore.updateProfile({ nickname })
originalNickname.value = nickname
form.value = { nickname }
uni.showToast({ title: '保存成功', icon: 'success' })
if (isFromLogin.value) {
uni.showToast({ title: '欢迎加入!', icon: 'success' })
setTimeout(() => {
uni.navigateBack()
}, TOAST_DISPLAY_MS)
} else {
uni.showToast({ title: '保存成功', icon: 'success' })
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : '保存失败,请重试'
uni.showToast({ title: msg, icon: 'none' })
@@ -215,10 +260,27 @@ async function handleSave() {
}
}
// ─── Skip (first-login only) ─────────────────────────────
function handleSkip() {
uni.showModal({
title: '确认跳过?',
content: '完善头像和昵称可以让教练和伙伴更容易认识你',
confirmText: '去完善',
cancelText: '跳过',
success(res) {
if (!res.confirm) {
uni.navigateBack()
}
},
})
}
// ─── Lifecycle ────────────────────────────────────────────
onMounted(async () => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
await userStore.fetchProfile()
if (!isFromLogin.value) {
await userStore.fetchProfile()
}
if (userStore.user) {
form.value = { nickname: userStore.user.nickname }
originalNickname.value = userStore.user.nickname
@@ -229,7 +291,52 @@ onMounted(async () => {
<style lang="scss" scoped>
.info-page {
min-height: 100vh;
background: #f5f3f0;
background: $bg-page;
}
/* ── Welcome banner (first-login) ───────────────────── */
.welcome-banner {
position: relative;
margin: 0 $spacing-lg $spacing-md;
padding: 36rpx 32rpx;
background: linear-gradient(135deg, $brand-color 0%, #6b5d52 100%);
border-radius: $radius-lg;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: -40rpx;
right: -20rpx;
width: 180rpx;
height: 180rpx;
background: radial-gradient(circle, rgba(255, 255, 255, 0.15) 0%, transparent 70%);
pointer-events: none;
}
}
.welcome-content {
position: relative;
z-index: 1;
}
.welcome-text {
display: flex;
flex-direction: column;
gap: 6rpx;
}
.welcome-title {
font-size: 34rpx;
font-weight: 700;
color: #ffffff;
letter-spacing: 2rpx;
}
.welcome-desc {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.75);
line-height: 1.5;
}
/* ── Avatar section ──────────────────────────────────── */
@@ -238,9 +345,13 @@ onMounted(async () => {
flex-direction: column;
align-items: center;
padding: 56rpx 0 40rpx;
background: #fff;
margin-bottom: 24rpx;
border-bottom: 1rpx solid #f0ece8;
background: $bg-card;
margin-bottom: $spacing-md;
border-bottom: 1rpx solid $border-color;
&--welcome {
padding-top: 40rpx;
}
}
.avatar-btn {
@@ -265,14 +376,14 @@ onMounted(async () => {
width: 160rpx;
height: 160rpx;
border-radius: 50%;
border: 4rpx solid #f0f0f0;
border: 4rpx solid $border-color;
}
.avatar-placeholder {
width: 160rpx;
height: 160rpx;
border-radius: 50%;
background: linear-gradient(135deg, $primary-dark, $primary-color);
background: linear-gradient(135deg, $brand-color, $accent-color);
display: flex;
align-items: center;
justify-content: center;
@@ -284,25 +395,45 @@ onMounted(async () => {
color: #fff;
}
.avatar-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 52rpx;
background: rgba(0, 0, 0, 0.45);
border-radius: 0 0 80rpx 80rpx;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.85;
}
.avatar-overlay-text {
font-size: 20rpx;
color: #ffffff;
letter-spacing: 1rpx;
}
.avatar-name {
font-size: 34rpx;
font-weight: 700;
color: #1a1a1a;
color: $text-primary;
margin-bottom: 6rpx;
}
.avatar-hint {
font-size: 22rpx;
color: #bbb;
color: $text-hint;
}
/* ── Form card ───────────────────────────────────────── */
.form-card {
background: #fff;
border-radius: 20rpx;
margin: 0 24rpx 20rpx;
background: $bg-card;
border-radius: $radius-lg;
margin: 0 $spacing-lg $spacing-md;
overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
}
.form-row {
@@ -310,7 +441,7 @@ onMounted(async () => {
flex-direction: row;
align-items: center;
padding: 32rpx 28rpx;
border-bottom: 1rpx solid #f5f5f5;
border-bottom: 1rpx solid rgba($border-color, 0.5);
min-height: 100rpx;
&--last {
@@ -320,7 +451,7 @@ onMounted(async () => {
.form-label {
font-size: 28rpx;
color: #555;
color: $text-secondary;
width: 120rpx;
flex-shrink: 0;
font-weight: 500;
@@ -329,7 +460,7 @@ onMounted(async () => {
.form-input {
flex: 1;
font-size: 28rpx;
color: #222;
color: $text-primary;
text-align: right;
background: transparent;
min-height: 44rpx;
@@ -338,13 +469,13 @@ onMounted(async () => {
.form-value {
flex: 1;
font-size: 28rpx;
color: #888;
color: $text-hint;
text-align: right;
}
.form-arrow {
font-size: 36rpx;
color: #ccc;
color: $text-hint;
margin-left: 8rpx;
line-height: 1;
}
@@ -369,18 +500,18 @@ onMounted(async () => {
.bind-phone-text {
font-size: 26rpx;
color: $primary-dark;
color: $accent-color;
font-weight: 600;
text-decoration: underline;
}
/* ── Read-only info card ──────────────────────────────── */
.info-card {
background: #fff;
border-radius: 20rpx;
margin: 0 24rpx 32rpx;
background: $bg-card;
border-radius: $radius-lg;
margin: 0 $spacing-lg $spacing-lg;
overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
}
.info-row {
@@ -389,7 +520,7 @@ onMounted(async () => {
align-items: center;
justify-content: space-between;
padding: 28rpx 28rpx;
border-bottom: 1rpx solid #f5f5f5;
border-bottom: 1rpx solid rgba($border-color, 0.5);
&--last {
border-bottom: none;
@@ -398,33 +529,38 @@ onMounted(async () => {
.info-label {
font-size: 26rpx;
color: #999;
color: $text-hint;
}
.info-value {
font-size: 26rpx;
color: #555;
color: $text-secondary;
font-weight: 500;
}
/* ── Save button ─────────────────────────────────────── */
.save-wrap {
padding: 8rpx 24rpx 48rpx;
padding: 8rpx $spacing-lg 48rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 24rpx;
}
.save-btn {
width: 100%;
height: 96rpx;
border-radius: 48rpx;
background: linear-gradient(90deg, #1a1a2e, #2d2d5e);
background: linear-gradient(135deg, $brand-color, #5e5045);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 20rpx rgba(26, 26, 46, 0.3);
transition: opacity 0.2s;
box-shadow: 0 8rpx 24rpx rgba($brand-color, 0.25);
transition: all 0.25s ease;
&:active {
opacity: 0.85;
transform: scale(0.98);
box-shadow: 0 4rpx 12rpx rgba($brand-color, 0.2);
}
&--loading,
@@ -437,7 +573,17 @@ onMounted(async () => {
.save-btn-text {
font-size: 32rpx;
font-weight: 700;
color: $primary-dark;
color: #ffffff;
letter-spacing: 2rpx;
}
.skip-text {
font-size: 26rpx;
color: $text-hint;
padding: 8rpx 24rpx;
&:active {
opacity: 0.6;
}
}
</style>

View File

@@ -0,0 +1,504 @@
<template>
<view class="invite-page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="邀请好友" show-back />
<scroll-view class="invite-scroll" scroll-y>
<view class="hero-card">
<view class="hero-glow hero-glow--one" />
<view class="hero-glow hero-glow--two" />
<text class="hero-badge">会员专享裂变活动</text>
<text class="hero-title"> 3 位好友体验并核销</text>
<text class="hero-subtitle">好友购买体验课并完成上课后会员卡立即奖励 1 节正课次数</text>
<view class="hero-stats">
<view class="hero-stat">
<text class="hero-stat-value">{{ summary?.qualifiedInviteCount ?? 0 }}</text>
<text class="hero-stat-label">已完成邀请</text>
</view>
<view class="hero-stat hero-stat--accent">
<text class="hero-stat-value">{{ summary?.rewardedTimes ?? 0 }}</text>
<text class="hero-stat-label">已得奖励</text>
</view>
<view class="hero-stat">
<text class="hero-stat-value">{{ summary?.nextRewardRemainingCount ?? 3 }}</text>
<text class="hero-stat-label">距下次奖励</text>
</view>
</view>
<view class="progress-shell">
<view class="progress-track">
<view class="progress-fill" :style="{ width: progressWidth }" />
</view>
<text class="progress-caption">本轮进度 {{ summary?.currentCycleQualifiedCount ?? 0 }}/{{ summary?.rewardRuleInvitesRequired ?? 3 }}</text>
</view>
<button class="share-btn" open-type="share">
立即邀请好友
</button>
<text class="share-hint">分享后新用户登录并购买体验课即可自动绑定邀请关系</text>
</view>
<view class="steps-card">
<text class="section-title">活动规则</text>
<view v-for="item in ruleSteps" :key="item.title" class="step-item">
<view class="step-index">{{ item.index }}</view>
<view class="step-body">
<text class="step-title">{{ item.title }}</text>
<text class="step-desc">{{ item.desc }}</text>
</view>
</view>
</view>
<view class="referrals-card">
<view class="section-head">
<text class="section-title">邀请进度</text>
<text class="section-meta">待完成 {{ summary?.pendingInviteCount ?? 0 }} </text>
</view>
<view v-if="summary?.referrals?.length" class="referral-list">
<view v-for="item in summary.referrals" :key="item.id" class="referral-item">
<image v-if="item.inviteeAvatarUrl" class="referral-avatar" :src="item.inviteeAvatarUrl" mode="aspectFill" />
<view v-else class="referral-avatar referral-avatar--placeholder"></view>
<view class="referral-main">
<text class="referral-name">{{ item.inviteeNickname || '新好友' }}</text>
<text class="referral-time">邀请于 {{ formatDateTime(item.invitedAt) }}</text>
</view>
<text class="referral-status" :class="statusClass(item.status)">{{ statusLabel(item.status) }}</text>
</view>
</view>
<view v-else class="empty-block">
<text class="empty-title">还没有邀请记录</text>
<text class="empty-desc">先分享给 3 位好友完成一次体验闭环就会在这里点亮进度</text>
</view>
</view>
<view class="reward-card">
<view class="section-head">
<text class="section-title">奖励记录</text>
<text class="section-meta">累计 {{ summary?.rewardedTimes ?? 0 }} </text>
</view>
<view v-if="summary?.rewardGrants?.length" class="reward-list">
<view v-for="item in summary.rewardGrants" :key="item.id" class="reward-item">
<text class="reward-item-title">完成 {{ item.qualifiedReferralCount }} 位好友核销</text>
<text class="reward-item-time">{{ formatDateTime(item.grantedAt) }}</text>
<text class="reward-item-tag">+{{ item.rewardTimes }} </text>
</view>
</view>
<view v-else class="empty-block empty-block--warm">
<text class="empty-title">还未获得奖励</text>
<text class="empty-desc"> 3 位好友完成体验核销系统自动增加 1 节真实会员课次</text>
</view>
</view>
<view class="bottom-space" />
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { onLoad, onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { InviteReferralStatus } from '@mp-pilates/shared'
import { useInviteStore } from '../../stores/invite'
import { useUserStore } from '../../stores/user'
import { getSystemLayout } from '../../utils/system'
import { formatDateTime } from '../../utils/format'
import CustomNavBar from '../../components/CustomNavBar.vue'
const inviteStore = useInviteStore()
const userStore = useUserStore()
const navBarHeight = ref('64px')
const summary = computed(() => inviteStore.activity)
const progressWidth = computed(() => {
const current = summary.value?.currentCycleQualifiedCount ?? 0
const total = summary.value?.rewardRuleInvitesRequired ?? 3
return `${Math.min(100, (current / total) * 100)}%`
})
const ruleSteps = [
{ index: '01', title: '分享活动页', desc: '会员用户把活动页转发给微信好友或朋友圈。' },
{ index: '02', title: '好友购买体验课', desc: '新好友通过你的分享进入,并成功购买体验课。' },
{ index: '03', title: '体验课完成核销', desc: '好友到店体验并被老师核销后,这次邀请记为有效。' },
{ index: '04', title: '满 3 人自动加课', desc: '每累计 3 位有效邀请,系统自动给你的会员卡增加 1 节。' },
]
onLoad((query) => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
const inviterId = typeof query?.inviterId === 'string' ? query.inviterId : ''
if (inviterId) {
uni.setStorageSync('invite_inviter_id', inviterId)
}
})
onShow(async () => {
if (!userStore.loggedIn) {
return
}
await Promise.all([
userStore.fetchProfile(),
inviteStore.fetchActivity(),
])
})
onShareAppMessage(() => ({
title: '邀 3 位好友体验核销,立得 1 节会员正课',
path: summary.value?.sharePath || `/pages/profile/invite?inviterId=${userStore.user?.id || ''}`,
imageUrl: '',
}))
onShareTimeline(() => ({
title: '邀 3 位好友体验核销,立得 1 节会员正课',
query: `inviterId=${userStore.user?.id || ''}`,
}))
function statusLabel(status: InviteReferralStatus): string {
const map: Record<InviteReferralStatus, string> = {
[InviteReferralStatus.REGISTERED]: '已注册',
[InviteReferralStatus.TRIAL_PURCHASED]: '已购体验课',
[InviteReferralStatus.QUALIFIED]: '已完成核销',
}
return map[status]
}
function statusClass(status: InviteReferralStatus): string {
if (status === InviteReferralStatus.QUALIFIED) return 'referral-status--done'
if (status === InviteReferralStatus.TRIAL_PURCHASED) return 'referral-status--paid'
return 'referral-status--registered'
}
</script>
<style lang="scss" scoped>
.invite-page {
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(255, 142, 83, 0.28), transparent 34%),
radial-gradient(circle at top right, rgba(255, 214, 102, 0.34), transparent 26%),
linear-gradient(180deg, #fff5db 0%, #ffe7ea 30%, #fef7ff 100%);
}
.invite-scroll {
height: 100vh;
padding: 24rpx;
box-sizing: border-box;
}
.hero-card,
.steps-card,
.referrals-card,
.reward-card {
position: relative;
overflow: hidden;
border-radius: 36rpx;
padding: 32rpx;
margin-bottom: 24rpx;
box-shadow: 0 18rpx 50rpx rgba(157, 70, 42, 0.08);
}
.hero-card {
background: linear-gradient(135deg, #ff7a45 0%, #ff4d6d 48%, #ffb347 100%);
color: #fff;
}
.hero-glow {
position: absolute;
border-radius: 50%;
opacity: 0.28;
background: rgba(255, 255, 255, 0.5);
}
.hero-glow--one {
width: 260rpx;
height: 260rpx;
top: -90rpx;
right: -40rpx;
}
.hero-glow--two {
width: 180rpx;
height: 180rpx;
bottom: -50rpx;
left: -40rpx;
}
.hero-badge {
display: inline-flex;
align-self: flex-start;
padding: 10rpx 18rpx;
background: rgba(255, 255, 255, 0.18);
border-radius: 999rpx;
font-size: 22rpx;
margin-bottom: 18rpx;
}
.hero-title {
display: block;
font-size: 52rpx;
font-weight: 700;
line-height: 1.18;
}
.hero-subtitle {
display: block;
margin-top: 18rpx;
font-size: 26rpx;
line-height: 1.7;
color: rgba(255, 255, 255, 0.92);
}
.hero-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 18rpx;
margin-top: 28rpx;
}
.hero-stat {
padding: 24rpx 18rpx;
border-radius: 26rpx;
background: rgba(255, 255, 255, 0.14);
backdrop-filter: blur(10rpx);
}
.hero-stat--accent {
background: rgba(75, 16, 16, 0.22);
}
.hero-stat-value {
display: block;
font-size: 46rpx;
font-weight: 700;
}
.hero-stat-label {
display: block;
margin-top: 8rpx;
font-size: 22rpx;
color: rgba(255, 255, 255, 0.86);
}
.progress-shell {
margin-top: 28rpx;
}
.progress-track {
height: 20rpx;
border-radius: 999rpx;
overflow: hidden;
background: rgba(255, 255, 255, 0.25);
}
.progress-fill {
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #fff7ad 0%, #ffffff 100%);
}
.progress-caption {
display: block;
margin-top: 12rpx;
font-size: 22rpx;
color: rgba(255, 255, 255, 0.88);
}
.share-btn {
margin-top: 28rpx;
height: 96rpx;
line-height: 96rpx;
border-radius: 999rpx;
font-size: 30rpx;
font-weight: 700;
color: #ff5a3c;
background: linear-gradient(90deg, #fff7e4 0%, #ffffff 100%);
border: none;
}
.share-btn::after {
border: none;
}
.share-hint {
display: block;
margin-top: 14rpx;
font-size: 22rpx;
color: rgba(255, 255, 255, 0.82);
}
.steps-card {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(255, 247, 234, 0.92));
}
.section-title {
display: block;
font-size: 32rpx;
font-weight: 700;
color: #30201a;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 22rpx;
}
.section-meta {
font-size: 22rpx;
color: #9b6b55;
}
.step-item {
display: flex;
gap: 18rpx;
align-items: flex-start;
padding: 22rpx 0;
border-bottom: 1rpx solid rgba(214, 171, 134, 0.2);
}
.step-item:last-child {
border-bottom: none;
}
.step-index {
width: 64rpx;
height: 64rpx;
border-radius: 20rpx;
text-align: center;
line-height: 64rpx;
font-size: 24rpx;
font-weight: 700;
color: #fff;
background: linear-gradient(135deg, #ff8f5a 0%, #ff4d6d 100%);
}
.step-body {
flex: 1;
}
.step-title {
display: block;
font-size: 28rpx;
font-weight: 600;
color: #36231d;
}
.step-desc {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
line-height: 1.7;
color: #7b5d52;
}
.referrals-card {
background: linear-gradient(180deg, #ffffff 0%, #fff7fb 100%);
}
.reward-card {
background: linear-gradient(180deg, #fffdf5 0%, #fff2dc 100%);
}
.referral-item,
.reward-item {
display: flex;
align-items: center;
padding: 22rpx 0;
border-bottom: 1rpx solid rgba(221, 196, 177, 0.35);
}
.referral-item:last-child,
.reward-item:last-child {
border-bottom: none;
}
.referral-avatar {
width: 78rpx;
height: 78rpx;
border-radius: 50%;
margin-right: 18rpx;
background: #ffd9c8;
}
.referral-avatar--placeholder {
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
font-weight: 700;
color: #ff6f3c;
}
.referral-main {
flex: 1;
}
.referral-name,
.reward-item-title {
display: block;
font-size: 28rpx;
font-weight: 600;
color: #31211a;
}
.referral-time,
.reward-item-time {
display: block;
margin-top: 8rpx;
font-size: 22rpx;
color: #8b6d62;
}
.referral-status,
.reward-item-tag {
padding: 12rpx 18rpx;
border-radius: 999rpx;
font-size: 22rpx;
font-weight: 600;
}
.referral-status--registered {
color: #9c5e2f;
background: #fff0de;
}
.referral-status--paid {
color: #c44f1f;
background: #ffe0d1;
}
.referral-status--done,
.reward-item-tag {
color: #0f7a53;
background: #dff7ea;
}
.empty-block {
padding: 36rpx 0 10rpx;
text-align: center;
}
.empty-block--warm {
padding-bottom: 0;
}
.empty-title {
display: block;
font-size: 28rpx;
font-weight: 600;
color: #5f4337;
}
.empty-desc {
display: block;
margin-top: 12rpx;
font-size: 24rpx;
line-height: 1.8;
color: #9c7d70;
}
.bottom-space {
height: 48rpx;
}
</style>

View File

@@ -1,7 +1,6 @@
<template>
<view class="membership-page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="我的会员卡" show-back />
<!-- Pull-to-refresh scroll view -->
<scroll-view
class="scroll"
scroll-y
@@ -16,11 +15,14 @@
<!-- Empty state -->
<view v-else-if="allMemberships.length === 0" class="empty-wrap">
<text class="empty-icon">💳</text>
<text class="empty-title">暂无会员卡</text>
<text class="empty-sub">购买会员卡后即可预约课程</text>
<view class="empty-btn" @tap="goStore">
<text class="empty-btn-text">购买</text>
<view class="empty-card">
<view class="empty-deco empty-deco--1" />
<view class="empty-deco empty-deco--2" />
<text class="empty-title">还没有会员卡</text>
<text class="empty-sub">购买会员卡后即可预约课程</text>
<view class="empty-btn" @tap="goStore">
<text class="empty-btn-text">去选购</text>
</view>
</view>
</view>
@@ -29,7 +31,6 @@
<!-- Active cards -->
<view v-if="activeMemberships.length > 0" class="group-section">
<view class="group-header">
<view class="group-dot group-dot--active" />
<text class="group-title">有效会员卡</text>
<text class="group-count">{{ activeMemberships.length }} </text>
</view>
@@ -37,56 +38,60 @@
<view
v-for="m in activeMemberships"
:key="m.id"
class="card-item"
class="mc"
:class="cardBgClass(m.cardType.type)"
>
<!-- Colored left border strip -->
<view class="card-strip" :class="stripClass(m.cardType.type)" />
<!-- Decorative circles -->
<view class="mc-deco mc-deco--1" />
<view class="mc-deco mc-deco--2" />
<!-- Card header (colored gradient) -->
<view class="card-header" :class="headerClass(m.cardType.type)">
<view class="card-header-left">
<text class="card-name">{{ m.cardType.name }}</text>
<view class="card-type-badge">
<text class="card-type-badge-text">{{ typeLabel(m.cardType.type) }}</text>
<!-- Top row: name + status -->
<view class="mc-top">
<view class="mc-name-area">
<text class="mc-name">{{ m.cardType.name }}</text>
<view class="mc-type-tag">
<text class="mc-type-text">{{ getCardTypeLabel(m.cardType.type) }}</text>
</view>
</view>
<view class="status-badge status-badge--active">
<text class="status-badge-text">有效</text>
<view class="mc-status mc-status--active">
<view class="mc-status-dot" />
<text class="mc-status-text">有效</text>
</view>
</view>
<!-- Card body -->
<view class="card-body">
<!-- Times card: remaining times + progress -->
<template v-if="m.remainingTimes !== null">
<view class="highlight-row">
<text class="highlight-label">剩余课时</text>
<text class="highlight-value">
<text class="highlight-number">{{ m.remainingTimes }}</text>
<text class="highlight-unit"> </text>
</text>
<!-- Center: highlight number (times card) -->
<view v-if="m.remainingTimes !== null" class="mc-center">
<text class="mc-big-num">{{ m.remainingTimes }}</text>
<text class="mc-big-unit">次剩余</text>
<view v-if="m.cardType.totalTimes" class="mc-progress">
<view class="mc-progress-track">
<view
class="mc-progress-fill"
:style="{ width: getMembershipProgressWidth(m) }"
/>
</view>
<view v-if="m.cardType.totalTimes" class="progress-wrap">
<view class="progress-bar">
<view
class="progress-fill"
:style="{ width: progressWidth(m) }"
/>
</view>
<text class="progress-label">
已使用 {{ usedTimes(m) }} / {{ m.cardType.totalTimes }}
</text>
</view>
</template>
<!-- Duration card: expiry -->
<view class="info-row">
<text class="info-label">有效期至</text>
<text class="info-value">{{ m.expireDate.slice(0, 10) }}</text>
<text class="mc-progress-label">
已用 {{ getMembershipUsedTimes(m) }} {{ m.cardType.totalTimes }}
</text>
</view>
<view class="info-row">
<text class="info-label">开始日期</text>
<text class="info-value">{{ m.startDate.slice(0, 10) }}</text>
</view>
<!-- Center: duration card (no times) -->
<view v-else class="mc-center">
<text class="mc-big-num">{{ daysRemaining(m) }}</text>
<text class="mc-big-unit">天剩余</text>
</view>
<!-- Bottom: dates -->
<view class="mc-bottom">
<view class="mc-date-item">
<text class="mc-date-label">开始</text>
<text class="mc-date-value">{{ m.startDate.slice(0, 10) }}</text>
</view>
<view class="mc-date-sep" />
<view class="mc-date-item">
<text class="mc-date-label">到期</text>
<text class="mc-date-value">{{ m.expireDate.slice(0, 10) }}</text>
</view>
</view>
</view>
@@ -95,7 +100,6 @@
<!-- Expired / used up cards -->
<view v-if="inactiveMemberships.length > 0" class="group-section">
<view class="group-header">
<view class="group-dot group-dot--inactive" />
<text class="group-title">历史记录</text>
<text class="group-count">{{ inactiveMemberships.length }} </text>
</view>
@@ -103,28 +107,30 @@
<view
v-for="m in inactiveMemberships"
:key="m.id"
class="card-item card-item--inactive"
class="mc mc--inactive"
>
<view class="card-strip card-strip--inactive" />
<view class="card-header card-header--inactive">
<view class="card-header-left">
<text class="card-name card-name--dim">{{ m.cardType.name }}</text>
<view class="card-type-badge card-type-badge--dim">
<text class="card-type-badge-text">{{ typeLabel(m.cardType.type) }}</text>
<view class="mc-deco mc-deco--1" />
<view class="mc-top">
<view class="mc-name-area">
<text class="mc-name">{{ m.cardType.name }}</text>
<view class="mc-type-tag">
<text class="mc-type-text">{{ getCardTypeLabel(m.cardType.type) }}</text>
</view>
</view>
<view class="status-badge" :class="statusBadgeClass(m.status)">
<text class="status-badge-text">{{ statusLabel(m.status) }}</text>
<view class="mc-status" :class="inactiveStatusClass(m.status)">
<text class="mc-status-text">{{ statusLabel(m.status) }}</text>
</view>
</view>
<view class="card-body">
<view v-if="m.remainingTimes !== null" class="info-row">
<text class="info-label">剩余课时</text>
<text class="info-value">{{ m.remainingTimes }} </text>
<view class="mc-inactive-info">
<view v-if="m.remainingTimes !== null" class="mc-date-item">
<text class="mc-date-label">剩余</text>
<text class="mc-date-value">{{ m.remainingTimes }} </text>
</view>
<view class="info-row">
<text class="info-label">有效期至</text>
<text class="info-value">{{ m.expireDate.slice(0, 10) }}</text>
<view class="mc-date-item">
<text class="mc-date-label">有效期至</text>
<text class="mc-date-value">{{ m.expireDate.slice(0, 10) }}</text>
</view>
</view>
</view>
@@ -136,8 +142,7 @@
<!-- Buy more FAB -->
<view class="fab" @tap="goStore">
<text class="fab-icon">+</text>
<text class="fab-text">购买会员卡</text>
<text class="fab-text">+ 购买会员卡</text>
</view>
</view>
</template>
@@ -148,17 +153,15 @@ import type { MembershipWithCardType } from '@mp-pilates/shared'
import { MembershipStatus, CardTypeCategory } from '@mp-pilates/shared'
import { useUserStore } from '../../stores/user'
import { getSystemLayout } from '../../utils/system'
import { getCardTypeLabel, getMembershipProgressWidth, getMembershipUsedTimes } from '../../utils/format'
import CustomNavBar from '../../components/CustomNavBar.vue'
const userStore = useUserStore()
// ─── Nav bar height ──────────────────────────────────────
const navBarHeight = ref('64px')
// ─── State ────────────────────────────────────────────────
const loading = ref(false)
const refreshing = ref(false)
// ─── Computed from store ───────────────────────────────────
const allMemberships = computed(() => userStore.memberships as MembershipWithCardType[])
const activeMemberships = computed(() =>
@@ -169,16 +172,6 @@ const inactiveMemberships = computed(() =>
allMemberships.value.filter((m) => m.status !== MembershipStatus.ACTIVE),
)
// ─── Helpers ──────────────────────────────────────────────
function typeLabel(type: CardTypeCategory): string {
const map: Record<CardTypeCategory, string> = {
[CardTypeCategory.TIMES]: '次卡',
[CardTypeCategory.DURATION]: '月卡',
[CardTypeCategory.TRIAL]: '体验卡',
}
return map[type] ?? '会员卡'
}
function statusLabel(status: MembershipStatus): string {
const map: Record<MembershipStatus, string> = {
[MembershipStatus.ACTIVE]: '有效',
@@ -188,36 +181,22 @@ function statusLabel(status: MembershipStatus): string {
return map[status] ?? status
}
function statusBadgeClass(status: MembershipStatus): string {
if (status === MembershipStatus.EXPIRED) return 'status-badge--expired'
if (status === MembershipStatus.USED_UP) return 'status-badge--used'
return 'status-badge--expired'
function inactiveStatusClass(status: MembershipStatus): string {
if (status === MembershipStatus.USED_UP) return 'mc-status--used'
return 'mc-status--expired'
}
function stripClass(type: CardTypeCategory): string {
if (type === CardTypeCategory.TRIAL) return 'card-strip--trial'
if (type === CardTypeCategory.DURATION) return 'card-strip--duration'
return 'card-strip--times'
function cardBgClass(type: CardTypeCategory): string {
if (type === CardTypeCategory.TRIAL) return 'mc--trial'
if (type === CardTypeCategory.DURATION) return 'mc--duration'
return 'mc--times'
}
function headerClass(type: CardTypeCategory): string {
if (type === CardTypeCategory.TRIAL) return 'card-header--trial'
if (type === CardTypeCategory.DURATION) return 'card-header--duration'
return 'card-header--times'
function daysRemaining(m: MembershipWithCardType): number {
const diff = new Date(m.expireDate).getTime() - Date.now()
return Math.max(0, Math.ceil(diff / 86_400_000))
}
function progressWidth(m: MembershipWithCardType): string {
if (m.remainingTimes === null || !m.cardType.totalTimes) return '0%'
const pct = (m.remainingTimes / m.cardType.totalTimes) * 100
return `${Math.max(0, Math.min(100, pct))}%`
}
function usedTimes(m: MembershipWithCardType): number {
if (m.remainingTimes === null || !m.cardType.totalTimes) return 0
return m.cardType.totalTimes - m.remainingTimes
}
// ─── Data loading ─────────────────────────────────────────
async function loadMemberships() {
loading.value = true
try {
@@ -239,7 +218,6 @@ function goStore() {
uni.switchTab({ url: '/pages/home/index' })
}
// ─── Lifecycle ────────────────────────────────────────────
onMounted(() => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
loadMemberships()
@@ -249,313 +227,373 @@ onMounted(() => {
<style lang="scss" scoped>
.membership-page {
min-height: 100vh;
background: #f5f3f0;
background: $bg-page;
}
.scroll {
height: 100vh;
}
/* ── Loading ─────────────────────────────────────────── */
/* ── Loading ─────────────────────────────── */
.loading-wrap {
padding: 24rpx;
display: flex;
flex-direction: column;
gap: 20rpx;
gap: 24rpx;
}
.skeleton-card {
height: 220rpx;
border-radius: 20rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
height: 320rpx;
border-radius: 24rpx;
background: linear-gradient(90deg, #f0ece8 25%, #e8e4df 50%, #f0ece8 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
/* ── Empty ───────────────────────────────────────────── */
/* ── Empty ────────────────────────────────── */
.empty-wrap {
padding: 80rpx 24rpx;
}
.empty-card {
position: relative;
overflow: hidden;
background: linear-gradient(135deg, #E8D5C4, #D8C8DC);
border-radius: 24rpx;
padding: 64rpx 40rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 40rpx;
gap: 20rpx;
gap: 16rpx;
}
.empty-icon {
font-size: 80rpx;
.empty-deco {
position: absolute;
border-radius: 50%;
pointer-events: none;
&--1 {
width: 200rpx;
height: 200rpx;
top: -60rpx;
right: -40rpx;
background: rgba(255, 255, 255, 0.3);
}
&--2 {
width: 140rpx;
height: 140rpx;
bottom: -40rpx;
left: -20rpx;
background: rgba(255, 255, 255, 0.2);
}
}
.empty-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
font-size: 34rpx;
font-weight: 700;
color: $brand-color;
z-index: 1;
}
.empty-sub {
font-size: 26rpx;
color: #999;
color: $text-secondary;
z-index: 1;
}
.empty-btn {
margin-top: 12rpx;
padding: 22rpx 60rpx;
border-radius: 44rpx;
background: $primary-dark;
box-shadow: 0 4rpx 16rpx rgba(201, 168, 124, 0.35);
margin-top: 16rpx;
padding: 20rpx 56rpx;
border-radius: 40rpx;
background: rgba(74, 64, 53, 0.12);
z-index: 1;
&:active { background: rgba(74, 64, 53, 0.18); }
}
.empty-btn-text {
font-size: 30rpx;
color: #fff;
font-size: 28rpx;
color: $brand-color;
font-weight: 600;
}
/* ── List ────────────────────────────────────────────── */
/* ── List ─────────────────────────────────── */
.list {
padding: 24rpx 24rpx 0;
padding: 16rpx 24rpx 0;
}
/* ── Group section ───────────────────────────────────── */
/* ── Group ────────────────────────────────── */
.group-section {
margin-bottom: 8rpx;
margin-bottom: 16rpx;
}
.group-header {
display: flex;
flex-direction: row;
align-items: center;
gap: 10rpx;
padding: 8rpx 4rpx 14rpx;
}
.group-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
flex-shrink: 0;
&--active { background: #4caf50; }
&--inactive { background: #bbb; }
justify-content: space-between;
padding: 12rpx 8rpx 16rpx;
}
.group-title {
font-size: 26rpx;
color: #555;
font-size: 28rpx;
color: $text-primary;
font-weight: 600;
flex: 1;
}
.group-count {
font-size: 22rpx;
color: #bbb;
color: $text-hint;
}
/* ── Card item ───────────────────────────────────────── */
.card-item {
background: #fff;
border-radius: 20rpx;
/* ══════════════════════════════════════════════
MEMBERSHIP CARD (mc)
══════════════════════════════════════════════ */
.mc {
position: relative;
overflow: hidden;
margin-bottom: 16rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.07);
border-radius: 24rpx;
padding: 28rpx 32rpx;
margin-bottom: 20rpx;
display: flex;
flex-direction: column;
gap: 24rpx;
}
&--inactive {
opacity: 0.72;
/* Card type backgrounds */
.mc--times {
background: linear-gradient(135deg, #EDE0D4 0%, #E2D2C2 100%);
box-shadow: 0 4rpx 20rpx rgba(212, 191, 168, 0.3);
}
.mc--duration {
background: linear-gradient(135deg, #E0D4E4 0%, #D4C6DA 100%);
box-shadow: 0 4rpx 20rpx rgba(196, 174, 203, 0.3);
}
.mc--trial {
background: linear-gradient(135deg, #D4E2DC 0%, #C6D8D0 100%);
box-shadow: 0 4rpx 20rpx rgba(169, 196, 188, 0.3);
}
.mc--inactive {
background: linear-gradient(135deg, #E8E4E0, #DDD9D5);
box-shadow: 0 2rpx 12rpx rgba(180, 160, 130, 0.12);
opacity: 0.75;
gap: 16rpx;
}
/* Decorative circles */
.mc-deco {
position: absolute;
border-radius: 50%;
pointer-events: none;
&--1 {
width: 180rpx;
height: 180rpx;
top: -50rpx;
right: -30rpx;
background: rgba(255, 255, 255, 0.3);
}
&--2 {
width: 120rpx;
height: 120rpx;
bottom: -30rpx;
left: 40rpx;
background: rgba(255, 255, 255, 0.2);
}
}
/* Colored left border strip */
.card-strip {
height: 6rpx;
&--times { background: linear-gradient(90deg, #1a1a2e, #2d2d5e); }
&--duration { background: linear-gradient(90deg, #6c3483, #9b59b6); }
&--trial { background: linear-gradient(90deg, #5a7a8a, $primary-dark); }
&--inactive { background: #ccc; }
}
/* Card header gradient area */
.card-header {
padding: 22rpx 28rpx;
/* ── Top row ──────────────────────────────── */
.mc-top {
display: flex;
flex-direction: row;
align-items: center;
align-items: flex-start;
justify-content: space-between;
&--times { background: linear-gradient(90deg, #1a1a2e, #2d2d5e); }
&--duration { background: linear-gradient(90deg, #6c3483, #9b59b6); }
&--trial { background: linear-gradient(90deg, #5a7a8a, $primary-dark); }
&--inactive { background: #888; }
z-index: 1;
}
.card-header-left {
.mc-name-area {
display: flex;
flex-direction: column;
gap: 8rpx;
}
.card-name {
font-size: 32rpx;
.mc-name {
font-size: 34rpx;
font-weight: 700;
color: #fff;
&--dim { color: #ddd; }
color: #2C2420;
line-height: 1.2;
}
.card-type-badge {
.mc-type-tag {
align-self: flex-start;
padding: 4rpx 14rpx;
border-radius: 12rpx;
background: rgba(255, 255, 255, 0.15);
border: 1rpx solid rgba(255, 255, 255, 0.25);
&--dim {
background: rgba(255, 255, 255, 0.08);
}
border-radius: 10rpx;
background: rgba(44, 36, 32, 0.1);
}
.card-type-badge-text {
.mc-type-text {
font-size: 20rpx;
color: rgba(255, 255, 255, 0.85);
color: rgba(44, 36, 32, 0.6);
font-weight: 500;
}
/* Status badge */
.status-badge {
padding: 8rpx 20rpx;
border-radius: 20rpx;
border: 1rpx solid rgba(255, 255, 255, 0.35);
/* Status */
.mc-status {
display: flex;
align-items: center;
gap: 8rpx;
padding: 6rpx 16rpx;
border-radius: 16rpx;
flex-shrink: 0;
&--active { background: rgba(76, 175, 80, 0.3); }
&--expired { background: rgba(0, 0, 0, 0.2); }
&--used { background: rgba(0, 0, 0, 0.2); }
}
.status-badge-text {
.mc-status--active {
background: rgba(122, 158, 126, 0.18);
}
.mc-status--expired,
.mc-status--used {
background: rgba(74, 64, 53, 0.08);
}
.mc-status-dot {
width: 10rpx;
height: 10rpx;
border-radius: 50%;
background: $success-color;
}
.mc-status-text {
font-size: 22rpx;
color: #fff;
color: #2C2420;
font-weight: 600;
}
/* Card body */
.card-body {
padding: 20rpx 28rpx 24rpx;
/* ── Center: big number ───────────────────── */
.mc-center {
display: flex;
flex-direction: column;
gap: 10rpx;
}
.highlight-row {
display: flex;
flex-direction: row;
align-items: baseline;
justify-content: space-between;
margin-bottom: 4rpx;
}
.highlight-label {
font-size: 26rpx;
color: #999;
}
.highlight-number {
font-size: 44rpx;
font-weight: 800;
color: $primary-dark;
line-height: 1;
}
.highlight-unit {
font-size: 22rpx;
color: $primary-dark;
font-weight: 500;
}
.info-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 8rpx 0;
z-index: 1;
}
.info-label {
font-size: 26rpx;
color: #999;
.mc-big-num {
font-size: 80rpx;
font-weight: 800;
color: #2C2420;
line-height: 1;
font-family: 'DIN Alternate', 'Helvetica Neue', Arial, sans-serif;
}
.info-value {
font-size: 26rpx;
color: #333;
.mc-big-unit {
font-size: 24rpx;
color: rgba(44, 36, 32, 0.55);
font-weight: 500;
margin-top: 4rpx;
}
/* ── Progress bar ────────────────────────────────────── */
.progress-wrap {
/* Progress */
.mc-progress {
width: 100%;
max-width: 400rpx;
margin-top: 20rpx;
display: flex;
flex-direction: column;
gap: 8rpx;
margin-bottom: 6rpx;
}
.progress-bar {
height: 8rpx;
background: #f0f0f0;
border-radius: 4rpx;
.mc-progress-track {
height: 10rpx;
background: rgba(44, 36, 32, 0.1);
border-radius: 5rpx;
overflow: hidden;
}
.progress-fill {
.mc-progress-fill {
height: 100%;
background: linear-gradient(90deg, $primary-dark, $primary-color);
border-radius: 4rpx;
background: rgba(44, 36, 32, 0.35);
border-radius: 5rpx;
transition: width 0.4s ease;
}
.progress-label {
font-size: 22rpx;
color: #bbb;
text-align: right;
.mc-progress-label {
font-size: 20rpx;
color: rgba(44, 36, 32, 0.45);
text-align: center;
}
/* ── FAB ─────────────────────────────────────────────── */
/* ── Bottom: dates ────────────────────────── */
.mc-bottom {
display: flex;
align-items: center;
justify-content: center;
gap: 0;
z-index: 1;
padding-top: 4rpx;
border-top: 1rpx solid rgba(44, 36, 32, 0.1);
}
.mc-date-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 4rpx;
}
.mc-date-sep {
width: 1rpx;
height: 40rpx;
background: rgba(44, 36, 32, 0.12);
flex-shrink: 0;
}
.mc-date-label {
font-size: 20rpx;
color: rgba(44, 36, 32, 0.4);
}
.mc-date-value {
font-size: 24rpx;
color: #2C2420;
font-weight: 600;
}
/* ── Inactive info ────────────────────────── */
.mc-inactive-info {
display: flex;
gap: 40rpx;
padding-left: 4rpx;
z-index: 1;
}
/* ── FAB ──────────────────────────────────── */
.fab {
position: fixed;
bottom: calc(32rpx + env(safe-area-inset-bottom));
right: 32rpx;
background: #1a1a2e;
background: $brand-color;
border-radius: 44rpx;
padding: 22rpx 36rpx;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.2);
box-shadow: 0 6rpx 24rpx rgba(74, 64, 53, 0.25);
z-index: 100;
display: flex;
flex-direction: row;
align-items: center;
gap: 8rpx;
&:active {
opacity: 0.85;
}
}
.fab-icon {
font-size: 36rpx;
color: $primary-dark;
font-weight: 300;
line-height: 1;
&:active { opacity: 0.85; }
}
.fab-text {
font-size: 28rpx;
font-weight: 700;
color: $primary-dark;
font-weight: 600;
color: #fff;
letter-spacing: 1rpx;
}
/* ── Spacer ──────────────────────────────────────────── */
/* ── Spacer ───────────────────────────────── */
.scroll-bottom-spacer {
height: 120rpx;
height: 140rpx;
}
</style>

View File

@@ -0,0 +1,582 @@
<template>
<view class="schedule-page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="我的课表" show-back />
<view class="schedule-hero">
<view class="schedule-hero__copy">
<text class="schedule-hero__eyebrow">Teaching Day</text>
<text class="schedule-hero__title">按日查看当天课程与学员</text>
<text class="schedule-hero__desc">只显示你当天有学员的课程按时间顺序一屏速览</text>
</view>
<view class="schedule-hero__meta">
<text class="schedule-hero__meta-num">{{ summary.slotCount }}</text>
<text class="schedule-hero__meta-label">节课程</text>
<text class="schedule-hero__meta-sub">{{ summary.studentCount }} 位学员</text>
</view>
</view>
<view class="schedule-toolbar">
<DateSelector v-model="selectedDate" variant="booking" @select="handleDateSelect" />
<view class="schedule-toolbar__summary">
<view class="schedule-toolbar__chip">
<text class="schedule-toolbar__chip-label">{{ dateLabel }}</text>
</view>
<view class="schedule-toolbar__chip schedule-toolbar__chip--soft">
<text class="schedule-toolbar__chip-label">{{ summaryRangeLabel }}</text>
</view>
</view>
</view>
<scroll-view
class="schedule-scroll"
scroll-y
refresher-enabled
:refresher-triggered="refreshing"
@refresherrefresh="handleRefresh"
>
<view v-if="loading && !refreshing" class="schedule-skeleton">
<view v-for="i in 3" :key="i" class="schedule-skeleton__card">
<view class="schedule-skeleton__time" />
<view class="schedule-skeleton__line schedule-skeleton__line--long" />
<view class="schedule-skeleton__line schedule-skeleton__line--short" />
</view>
</view>
<view v-else-if="slots.length === 0" class="schedule-empty">
<view class="schedule-empty__badge"></view>
<text class="schedule-empty__title">这一天没有已预约课程</text>
<text class="schedule-empty__desc">当前只展示有学员的课程安排空白日期不会出现占位时段</text>
</view>
<view v-else class="schedule-list">
<view v-for="slot in slots" :key="slot.slotId" class="schedule-card">
<view class="schedule-card__rail" />
<view class="schedule-card__header">
<view>
<text class="schedule-card__time">{{ slot.startTime.slice(0, 5) }}</text>
<text class="schedule-card__range">{{ slot.startTime.slice(0, 5) }} - {{ slot.endTime.slice(0, 5) }}</text>
</view>
<view class="schedule-card__count">
<text class="schedule-card__count-num">{{ slot.students.length }}</text>
<text class="schedule-card__count-label"></text>
</view>
</view>
<view class="schedule-card__body">
<view v-for="student in slot.students" :key="student.bookingId" class="student-row">
<view class="student-row__avatar">{{ getNameInitial(student.nickname) }}</view>
<view class="student-row__main">
<view class="student-row__headline">
<text class="student-row__name">{{ student.nickname || '未命名学员' }}</text>
<text class="student-row__status" :class="statusClass(student.status)">
{{ statusLabel(student.status) }}
</text>
</view>
<text v-if="student.phone" class="student-row__phone">{{ maskPhone(student.phone) }}</text>
<text v-else class="student-row__phone student-row__phone--muted">未绑定手机号</text>
</view>
</view>
</view>
</view>
</view>
<view class="schedule-bottom-space" />
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { storeToRefs } from 'pinia'
import type { TeachingScheduleSlot } from '@mp-pilates/shared'
import { BookingStatus } from '@mp-pilates/shared'
import CustomNavBar from '../../components/CustomNavBar.vue'
import DateSelector from '../../components/DateSelector.vue'
import { useBookingStore } from '../../stores/booking'
import { useUserStore } from '../../stores/user'
import { formatDate, getWeekdayLabel, isToday } from '../../utils/format'
import { getSystemLayout } from '../../utils/system'
import { getErrorMessage } from '../../utils/auth'
const bookingStore = useBookingStore()
const userStore = useUserStore()
const { teachingSchedule, loadingTeachingSchedule } = storeToRefs(bookingStore)
const { loggedIn, isAdmin } = storeToRefs(userStore)
const navBarHeight = ref('64px')
const selectedDate = ref(formatDate(new Date()))
const refreshing = ref(false)
const slots = computed<readonly TeachingScheduleSlot[]>(() => teachingSchedule.value)
const loading = computed(() => loadingTeachingSchedule.value)
const summary = computed(() => ({
slotCount: slots.value.length,
studentCount: slots.value.reduce((sum, slot) => sum + slot.students.length, 0),
}))
const dateLabel = computed(() => {
const label = `${selectedDate.value.slice(5, 7)}${selectedDate.value.slice(8, 10)}${getWeekdayLabel(selectedDate.value)}`
return isToday(selectedDate.value) ? `今天 · ${label}` : label
})
const summaryRangeLabel = computed(() => {
if (slots.value.length === 0) {
return '暂无课程'
}
const first = slots.value[0]
const last = slots.value[slots.value.length - 1]
return `${first.startTime.slice(0, 5)} - ${last.endTime.slice(0, 5)}`
})
const STATUS_LABELS: Record<BookingStatus, string> = {
[BookingStatus.PENDING_CONFIRMATION]: '待确认',
[BookingStatus.CONFIRMED]: '已确认',
[BookingStatus.CANCELLED]: '已取消',
[BookingStatus.COMPLETED]: '已完成',
[BookingStatus.NO_SHOW]: '未出席',
}
onMounted(() => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
})
onShow(() => {
if (!loggedIn.value) {
uni.showToast({ title: '请先登录', icon: 'none' })
return
}
if (!isAdmin.value) {
uni.showToast({ title: '仅管理员可查看', icon: 'none' })
return
}
loadSchedule(selectedDate.value)
})
function handleDateSelect(date: string) {
selectedDate.value = date
loadSchedule(date)
}
async function handleRefresh() {
refreshing.value = true
try {
await loadSchedule(selectedDate.value)
} finally {
refreshing.value = false
}
}
async function loadSchedule(date: string) {
try {
await bookingStore.fetchTeachingSchedule(date)
} catch (err: unknown) {
uni.showToast({ title: getErrorMessage(err, '课表加载失败'), icon: 'none' })
}
}
function getNameInitial(name: string): string {
const normalized = (name || '?').trim()
return normalized.slice(0, 1).toUpperCase()
}
function maskPhone(phone: string): string {
return `${phone.slice(0, 3)} ${phone.slice(3, 7)} ${phone.slice(7, 11)}`
}
function statusLabel(status: BookingStatus): string {
return STATUS_LABELS[status] ?? status
}
function statusClass(status: BookingStatus): string {
return status === BookingStatus.PENDING_CONFIRMATION
? 'student-row__status--pending'
: 'student-row__status--confirmed'
}
</script>
<style lang="scss" scoped>
.schedule-page {
min-height: 100vh;
background:
radial-gradient(circle at top right, rgba(93, 140, 138, 0.18), transparent 34%),
linear-gradient(180deg, #f3ede6 0%, #f7f4ef 30%, #fbfaf7 100%);
}
.schedule-hero {
margin: 24rpx 24rpx 20rpx;
padding: 32rpx 30rpx;
border-radius: 32rpx;
box-sizing: border-box;
background:
linear-gradient(145deg, rgba(60, 86, 92, 0.96), rgba(108, 137, 127, 0.92)),
#3e5b60;
color: #f8f5ef;
display: flex;
gap: 24rpx;
box-shadow: 0 22rpx 60rpx rgba(55, 84, 82, 0.18);
&__copy {
flex: 1;
display: flex;
flex-direction: column;
gap: 10rpx;
}
&__eyebrow {
font-size: 20rpx;
letter-spacing: 4rpx;
text-transform: uppercase;
color: rgba(248, 245, 239, 0.7);
}
&__title {
font-size: 38rpx;
line-height: 1.25;
font-weight: 700;
}
&__desc {
font-size: 24rpx;
line-height: 1.6;
color: rgba(248, 245, 239, 0.78);
}
&__meta {
width: 164rpx;
max-width: 100%;
border-radius: 24rpx;
padding: 22rpx 18rpx;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.12);
border: 1rpx solid rgba(255, 255, 255, 0.12);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
flex-shrink: 0;
text-align: center;
gap: 6rpx;
}
&__meta-num {
font-size: 52rpx;
font-weight: 700;
line-height: 1;
font-family: 'DIN Alternate', 'Helvetica Neue', Arial, sans-serif;
}
&__meta-label,
&__meta-sub {
font-size: 22rpx;
color: rgba(248, 245, 239, 0.78);
}
}
.schedule-toolbar {
position: sticky;
top: 0;
z-index: 10;
padding-bottom: 12rpx;
background: linear-gradient(180deg, rgba(247, 244, 239, 0.94), rgba(247, 244, 239, 0.74));
backdrop-filter: blur(14rpx);
&__summary {
display: flex;
gap: 12rpx;
padding: 16rpx 24rpx 0;
}
&__chip {
padding: 14rpx 22rpx;
border-radius: 999rpx;
background: #ffffff;
border: 1rpx solid rgba(93, 140, 138, 0.12);
box-shadow: 0 10rpx 24rpx rgba(80, 92, 82, 0.08);
&--soft {
background: rgba(255, 255, 255, 0.78);
}
}
&__chip-label {
font-size: 22rpx;
color: #5b6058;
}
}
.schedule-scroll {
height: calc(100vh - v-bind(navBarHeight));
}
.schedule-skeleton {
padding: 12rpx 24rpx 0;
display: flex;
flex-direction: column;
gap: 18rpx;
&__card {
border-radius: 28rpx;
padding: 28rpx;
background: rgba(255, 255, 255, 0.74);
}
&__time,
&__line {
border-radius: 999rpx;
background: linear-gradient(90deg, rgba(220, 223, 218, 0.7), rgba(239, 241, 238, 0.95), rgba(220, 223, 218, 0.7));
background-size: 300% 100%;
animation: shimmer 1.4s linear infinite;
}
&__time {
width: 180rpx;
height: 38rpx;
margin-bottom: 20rpx;
}
&__line {
height: 24rpx;
margin-top: 14rpx;
&--long {
width: 100%;
}
&--short {
width: 60%;
}
}
}
.schedule-empty {
margin: 40rpx 24rpx 0;
border-radius: 32rpx;
padding: 72rpx 40rpx;
background: rgba(255, 255, 255, 0.82);
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 14rpx;
box-shadow: 0 18rpx 44rpx rgba(108, 122, 112, 0.08);
&__badge {
width: 100rpx;
height: 100rpx;
border-radius: 32rpx;
background: linear-gradient(145deg, #e8ddd2, #f5efe8);
color: #7e7467;
display: flex;
align-items: center;
justify-content: center;
font-size: 44rpx;
font-weight: 700;
}
&__title {
font-size: 34rpx;
color: #3f403c;
font-weight: 600;
}
&__desc {
font-size: 24rpx;
color: #9b958b;
line-height: 1.7;
}
}
.schedule-list {
padding: 12rpx 24rpx 0;
display: flex;
flex-direction: column;
gap: 18rpx;
}
.schedule-card {
position: relative;
overflow: hidden;
border-radius: 30rpx;
padding: 28rpx 28rpx 18rpx 40rpx;
background: rgba(255, 255, 255, 0.86);
box-shadow: 0 20rpx 48rpx rgba(83, 95, 86, 0.1);
&__rail {
position: absolute;
top: 24rpx;
left: 18rpx;
bottom: 24rpx;
width: 8rpx;
border-radius: 999rpx;
background: linear-gradient(180deg, #5d8c8a, #d7c4b1);
}
&__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16rpx;
margin-bottom: 20rpx;
}
&__time {
display: block;
font-size: 46rpx;
line-height: 1;
font-weight: 700;
color: #304549;
font-family: 'DIN Alternate', 'Helvetica Neue', Arial, sans-serif;
}
&__range {
display: block;
margin-top: 8rpx;
font-size: 22rpx;
color: #8a8e86;
letter-spacing: 1rpx;
}
&__count {
min-width: 116rpx;
padding: 14rpx 16rpx;
border-radius: 20rpx;
background: #f3efe8;
text-align: center;
}
&__count-num {
font-size: 34rpx;
color: #6e5b4f;
font-weight: 700;
}
&__count-label {
margin-left: 4rpx;
font-size: 22rpx;
color: #907d6f;
}
&__body {
display: flex;
flex-direction: column;
gap: 16rpx;
}
}
.student-row {
display: flex;
gap: 18rpx;
padding: 20rpx 20rpx 20rpx 16rpx;
border-radius: 22rpx;
background: linear-gradient(135deg, rgba(246, 244, 239, 0.98), rgba(255, 255, 255, 0.9));
&__avatar {
width: 72rpx;
height: 72rpx;
border-radius: 24rpx;
background: linear-gradient(145deg, #5d8c8a, #86a99d);
color: #fff;
font-size: 28rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
&__main {
flex: 1;
min-width: 0;
}
&__headline {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12rpx;
}
&__name {
font-size: 30rpx;
font-weight: 600;
color: #313630;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__status {
flex-shrink: 0;
padding: 8rpx 14rpx;
border-radius: 999rpx;
font-size: 20rpx;
font-weight: 600;
&--pending {
background: rgba(206, 164, 96, 0.14);
color: #9b6e22;
}
&--confirmed {
background: rgba(93, 140, 138, 0.14);
color: #376a69;
}
}
&__phone {
display: block;
margin-top: 10rpx;
font-size: 24rpx;
color: #7b8179;
&--muted {
color: #b0b3ad;
}
}
}
.schedule-bottom-space {
height: 36rpx;
}
@media (max-width: 420px) {
.schedule-hero {
flex-direction: column;
align-items: stretch;
&__meta {
width: 100%;
margin: 0 auto;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 12rpx;
}
}
.schedule-toolbar__summary {
flex-wrap: wrap;
}
.schedule-card__header,
.student-row__headline {
flex-direction: column;
align-items: flex-start;
}
}
@keyframes shimmer {
from {
background-position: 200% 0;
}
to {
background-position: -100% 0;
}
}
</style>

View File

@@ -0,0 +1,491 @@
<template>
<view class="teacher-detail-page">
<CustomNavBar title="教练详情" show-back />
<scroll-view class="detail-scroll" scroll-y :style="{ height: scrollHeight }">
<view class="nav-spacer" :style="{ height: navBarHeight }" />
<view class="hero-section">
<image class="hero-image" :src="teacher.cover" mode="widthFix" />
<view class="hero-overlay" />
<view class="hero-content">
<view class="hero-badges">
<text v-for="item in teacher.badges" :key="item" class="hero-badge">{{ item }}</text>
</view>
<text class="hero-name">{{ teacher.name }}</text>
<text class="hero-title">{{ teacher.title }}</text>
</view>
</view>
<view class="content-wrap">
<view class="summary-card">
<view class="summary-head">
<view>
<text class="summary-eyebrow">Private Pilates Coach</text>
<text class="summary-title">为你定制更细腻的训练节奏</text>
</view>
</view>
<view class="specialty-row">
<text v-for="item in teacher.specialties" :key="item" class="specialty-pill">{{ item }}</text>
</view>
<text class="summary-intro">{{ teacher.intro }}</text>
<view class="stats-grid">
<view v-for="item in teacher.stats" :key="item.label" class="stat-item">
<text class="stat-value">{{ item.value }}</text>
<text class="stat-label">{{ item.label }}</text>
</view>
</view>
</view>
<view class="detail-card">
<text class="card-title">教练介绍</text>
<text class="card-text card-text--intro">
我是 Iris一名注重体态控制与身体感受的普拉提教练
</text>
<text class="card-text">
我希望带你在稳定安心的节奏里找回核心力量挺拔线条和更轻松的身体状态
</text>
</view>
<view class="detail-card">
<text class="card-title">认证背景</text>
<view v-for="item in teacher.certifications" :key="item" class="list-row">
<view class="list-dot" />
<text class="list-text">{{ item }}</text>
</view>
</view>
<view class="detail-card">
<text class="card-title">授课重点</text>
<view class="focus-grid">
<view v-for="item in teacher.teachingFocus" :key="item.title" class="focus-item">
<text class="focus-title">{{ item.title }}</text>
<text class="focus-desc">{{ item.desc }}</text>
</view>
</view>
</view>
<view class="detail-card cta-card">
<text class="card-title">适合这样的你</text>
<view class="fit-grid">
<view class="fit-pill">久坐肩颈紧张</view>
<view class="fit-pill">体态调整</view>
<view class="fit-pill">产后恢复</view>
<view class="fit-pill">塑形紧致</view>
<view class="fit-pill">核心无力</view>
<view class="fit-pill">运动入门</view>
</view>
<text class="card-text">
如果你想改善体态缓解肩颈腰背不适提升核心稳定线条感与身体控制力这里会是一个很好的开始
</text>
<view class="cta-inline" @tap="goToBooking">
<text class="cta-inline-text">去约 Iris 的课程</text>
<text class="cta-inline-arrow"></text>
</view>
</view>
<view class="bottom-space" />
</view>
</scroll-view>
<view class="bottom-bar">
<view class="bottom-note">
<text class="bottom-note-title">1 1 私教课</text>
<text class="bottom-note-sub">60 分钟 · 进入约课页选择时段</text>
</view>
<view class="booking-btn" @tap="goToBooking">
<text class="booking-btn-text">立即预约</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { irisProfile } from '../../utils/teacher'
import { getSystemLayout } from '../../utils/system'
const teacher = irisProfile
const navBarHeight = ref('64px')
const scrollHeight = ref('500px')
onShareAppMessage(() => {
return {
title: `${teacher.name}${teacher.title}`,
path: `/pages/teacher/detail?id=${teacher.id}`,
imageUrl: teacher.cover,
}
})
onShareTimeline(() => {
return {
title: `${teacher.name}${teacher.title}`,
query: `id=${teacher.id}`,
imageUrl: teacher.cover,
}
})
onLoad(() => {
updateLayout()
})
function updateLayout() {
const { navBarHeight: navBarPx } = getSystemLayout()
const { windowHeight } = uni.getWindowInfo()
navBarHeight.value = `${navBarPx}px`
scrollHeight.value = `${windowHeight}px`
}
function goToBooking() {
uni.switchTab({ url: '/pages/booking/index' })
}
</script>
<style lang="scss" scoped>
.teacher-detail-page {
height: 100vh;
background: #f7f2ee;
box-sizing: border-box;
}
.nav-spacer {
width: 100%;
flex-shrink: 0;
}
.hero-section {
position: relative;
height: 580rpx;
overflow: hidden;
}
.hero-image {
width: 100%;
display: block;
}
.hero-overlay {
position: absolute;
inset: 0;
background:
linear-gradient(180deg, rgba(33, 24, 22, 0.08) 0%, rgba(33, 24, 22, 0.08) 34%, rgba(33, 24, 22, 0.64) 100%),
radial-gradient(circle at top right, rgba(255, 228, 208, 0.26), transparent 28%);
}
.hero-content {
position: absolute;
left: 32rpx;
right: 32rpx;
bottom: 92rpx;
z-index: 1;
}
.hero-badges {
display: flex;
gap: 10rpx;
margin-bottom: 18rpx;
}
.hero-badge {
height: 42rpx;
padding: 0 16rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
background: rgba(255, 255, 255, 0.18);
border: 1rpx solid rgba(255, 255, 255, 0.24);
color: #fff8f3;
font-size: 20rpx;
backdrop-filter: blur(10rpx);
}
.hero-name {
display: block;
font-size: 56rpx;
font-weight: 700;
color: #ffffff;
line-height: 1.05;
margin-bottom: 10rpx;
}
.hero-title {
font-size: 26rpx;
color: rgba(255, 247, 241, 0.88);
}
.detail-scroll {
width: 100%;
}
.content-wrap {
position: relative;
margin-top: -8rpx;
padding: 0 24rpx;
}
.summary-card,
.detail-card {
background: rgba(255, 255, 255, 0.92);
border-radius: 28rpx;
box-shadow: 0 16rpx 36rpx rgba(120, 91, 79, 0.08);
backdrop-filter: blur(12rpx);
}
.summary-card {
padding: 28rpx;
margin-bottom: 18rpx;
}
.summary-head {
margin-bottom: 18rpx;
}
.summary-eyebrow {
display: block;
font-size: 20rpx;
color: #c09a89;
letter-spacing: 3rpx;
text-transform: uppercase;
margin-bottom: 8rpx;
}
.summary-title {
font-size: 34rpx;
line-height: 1.35;
font-weight: 700;
color: #2f2622;
}
.specialty-row {
display: flex;
flex-wrap: wrap;
gap: 10rpx;
margin-bottom: 18rpx;
}
.specialty-pill {
padding: 10rpx 18rpx;
border-radius: 999rpx;
background: rgba(92, 126, 151, 0.12);
color: #56778a;
font-size: 22rpx;
font-weight: 600;
}
.summary-intro,
.card-text,
.list-text {
font-size: 25rpx;
line-height: 1.78;
color: #5f534c;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14rpx;
margin-top: 24rpx;
}
.stat-item {
padding: 20rpx 16rpx;
border-radius: 20rpx;
background: linear-gradient(180deg, #fff8f4 0%, #f7f1ec 100%);
text-align: center;
}
.stat-value {
display: block;
font-size: 24rpx;
line-height: 1.45;
font-weight: 700;
color: #3f3029;
margin-bottom: 8rpx;
}
.stat-label {
font-size: 20rpx;
color: #9c8579;
}
.detail-card {
padding: 28rpx;
margin-bottom: 18rpx;
}
.card-title {
display: block;
font-size: 30rpx;
font-weight: 700;
color: #342925;
margin-bottom: 18rpx;
}
.card-text + .card-text {
margin-top: 16rpx;
}
.card-text--intro {
font-size: 28rpx;
line-height: 1.7;
font-weight: 600;
color: #43352f;
}
.list-row {
display: flex;
align-items: flex-start;
gap: 14rpx;
}
.list-row + .list-row {
margin-top: 18rpx;
}
.list-dot {
width: 14rpx;
height: 14rpx;
border-radius: 50%;
margin-top: 14rpx;
flex-shrink: 0;
background: linear-gradient(135deg, #ff8b69 0%, #ff593f 100%);
}
.focus-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14rpx;
}
.focus-item {
min-height: 164rpx;
border-radius: 22rpx;
padding: 22rpx 18rpx;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
background: linear-gradient(135deg, #fff8f5 0%, #f6eee9 100%);
}
.focus-title {
font-size: 24rpx;
font-weight: 600;
color: #5c4a42;
}
.focus-desc {
margin-top: 10rpx;
font-size: 21rpx;
line-height: 1.65;
color: #8a7569;
}
.cta-card {
background: linear-gradient(135deg, #fff6f1 0%, #fffdfc 100%);
}
.fit-grid {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-bottom: 18rpx;
}
.fit-pill {
padding: 10rpx 18rpx;
border-radius: 999rpx;
background: rgba(255, 126, 92, 0.12);
color: #b3573f;
font-size: 22rpx;
font-weight: 600;
}
.cta-inline {
margin-top: 22rpx;
height: 88rpx;
border-radius: 999rpx;
padding: 0 28rpx;
display: flex;
align-items: center;
justify-content: space-between;
background: linear-gradient(135deg, #ff7f5e 0%, #ff543c 100%);
box-shadow: 0 16rpx 28rpx rgba(255, 95, 73, 0.22);
}
.cta-inline-text,
.cta-inline-arrow {
color: #ffffff;
font-weight: 700;
}
.cta-inline-text {
font-size: 28rpx;
}
.cta-inline-arrow {
font-size: 38rpx;
line-height: 1;
}
.bottom-space {
height: 160rpx;
}
.bottom-bar {
position: fixed;
left: 24rpx;
right: 24rpx;
bottom: 24rpx;
z-index: 20;
display: flex;
align-items: center;
gap: 18rpx;
padding: 18rpx;
border-radius: 28rpx;
background: rgba(35, 29, 27, 0.9);
backdrop-filter: blur(18rpx);
box-shadow: 0 20rpx 48rpx rgba(36, 28, 26, 0.18);
}
.bottom-note {
flex: 1;
min-width: 0;
}
.bottom-note-title {
display: block;
font-size: 26rpx;
font-weight: 700;
color: #fffaf6;
margin-bottom: 6rpx;
}
.bottom-note-sub {
font-size: 20rpx;
color: rgba(255, 244, 236, 0.72);
}
.booking-btn {
width: 224rpx;
height: 88rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #ff815e 0%, #ff5136 100%);
}
.booking-btn-text {
font-size: 28rpx;
font-weight: 700;
color: #ffffff;
letter-spacing: 1rpx;
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -2,8 +2,6 @@ import { defineStore } from 'pinia'
import { ref } from 'vue'
import { get, post, put, del } from '../utils/request'
import type {
WeekTemplate,
WeekTemplateInput,
CardType,
CreateCardTypeDto,
UpdateCardTypeDto,
@@ -15,8 +13,40 @@ import type {
PaginatedData,
ScheduleSlotPreview,
PublishDaySlotsDto,
FlashSaleAdminItem,
CreateFlashSaleDto,
UpdateFlashSaleDto,
CreateStudioUploadCredentialDto,
StudioUploadCredential,
} from '@mp-pilates/shared'
interface LegacyPaginatedData<T> {
readonly data: readonly T[]
readonly total: number
readonly page: number
readonly limit: number
}
function normalizePaginatedData<T>(
result: PaginatedData<T> | LegacyPaginatedData<T>,
): PaginatedData<T> {
if ('items' in result) {
return {
items: [...result.items],
total: result.total,
page: result.page,
limit: result.limit,
}
}
return {
items: [...result.data],
total: result.total,
page: result.page,
limit: result.limit,
}
}
export interface AdminStats {
todayBookings: number
totalOrders: number
@@ -34,22 +64,26 @@ export interface MemberSummary {
cancelledBookings: number
}
export interface UserMembership {
userId: string
membership: {
id: string
cardTypeId: string
remainingTimes: number | null
startDate: string
expireDate: string
status: string
cardType: {
id: string
name: string
type: string
totalTimes: number | null
durationDays: number
}
} | null
}
export const useAdminStore = defineStore('admin', () => {
// ── Week templates ───────────────────────────────────────────────
const weekTemplates = ref<WeekTemplate[]>([])
async function fetchWeekTemplates(): Promise<WeekTemplate[]> {
const data = await get<WeekTemplate[]>('/admin/week-template')
weekTemplates.value = data
return data
}
async function saveWeekTemplates(templates: WeekTemplateInput[]): Promise<WeekTemplate[]> {
const data = await put<WeekTemplate[]>('/admin/week-template', { templates })
weekTemplates.value = data
return data
}
// ── Card types ───────────────────────────────────────────────────
const cardTypes = ref<CardType[]>([])
@@ -60,13 +94,13 @@ export const useAdminStore = defineStore('admin', () => {
}
async function createCardType(dto: CreateCardTypeDto): Promise<CardType> {
const data = await post<CardType>('/admin/card-types', dto)
const data = await post<CardType>('/admin/card-types', dto as unknown as Record<string, unknown>)
await fetchCardTypes()
return data
}
async function updateCardType(id: string, dto: UpdateCardTypeDto): Promise<CardType> {
const data = await put<CardType>(`/admin/card-types/${id}`, dto)
const data = await put<CardType>(`/admin/card-types/${id}`, dto as unknown as Record<string, unknown>)
await fetchCardTypes()
return data
}
@@ -87,18 +121,31 @@ export const useAdminStore = defineStore('admin', () => {
}
async function saveStudioConfig(dto: UpdateStudioConfigDto): Promise<StudioConfig> {
const data = await put<StudioConfig>('/admin/studio/info', dto)
const data = await put<StudioConfig>('/admin/studio/info', dto as unknown as Record<string, unknown>)
studioConfig.value = data
return data
}
async function createStudioUploadCredential(
dto: CreateStudioUploadCredentialDto,
): Promise<StudioUploadCredential> {
return post<StudioUploadCredential>(
'/admin/studio/upload-credentials',
dto as unknown as Record<string, unknown>,
)
}
// ── Orders ───────────────────────────────────────────────────────
async function fetchAdminOrders(params: {
page?: number
limit?: number
status?: string
}): Promise<PaginatedData<OrderWithDetails>> {
return get<PaginatedData<OrderWithDetails>>('/admin/orders', params)
const result = await get<PaginatedData<OrderWithDetails> | LegacyPaginatedData<OrderWithDetails>>(
'/admin/orders',
params,
)
return normalizePaginatedData(result)
}
// ── Bookings ─────────────────────────────────────────────────────
@@ -115,22 +162,43 @@ export const useAdminStore = defineStore('admin', () => {
page?: number
limit?: number
search?: string
cardType?: string
}): Promise<PaginatedData<MemberSummary>> {
// Filter out undefined/empty values to avoid sending "undefined" as string
const cleanParams: Record<string, unknown> = {}
if (params?.page != null) cleanParams.page = params.page
if (params?.limit != null) cleanParams.limit = params.limit
if (params?.search) cleanParams.search = params.search
if (params?.cardType) cleanParams.cardType = params.cardType
return get<PaginatedData<MemberSummary>>('/admin/members', cleanParams)
}
async function getUserMembership(userId: string): Promise<UserMembership> {
return get<UserMembership>(`/admin/members/${userId}/membership`)
}
async function updateUserMembership(
userId: string,
dto: {
cardTypeId: string
remainingTimes?: number | null
startDate: string
expireDate: string
},
): Promise<any> {
return put<any>(`/admin/members/${userId}/membership`, dto)
}
async function deleteUserMembership(userId: string): Promise<void> {
return del<void>(`/admin/members/${userId}/membership`)
}
// ── Time slots ───────────────────────────────────────────────────
async function fetchSlotsByDate(date: string): Promise<TimeSlot[]> {
return get<TimeSlot[]>('/admin/time-slots', { date })
}
async function createManualSlot(dto: CreateManualSlotDto): Promise<TimeSlot> {
return post<TimeSlot>('/admin/time-slot/manual', dto)
return post<TimeSlot>('/admin/time-slot/manual', dto as unknown as Record<string, unknown>)
}
async function closeSlot(id: string): Promise<TimeSlot> {
@@ -166,16 +234,32 @@ export const useAdminStore = defineStore('admin', () => {
return get<AdminStats>('/admin/stats')
}
// ── Flash sales ─────────────────────────────────────────────────
async function fetchFlashSales(params?: {
page?: number
limit?: number
}): Promise<PaginatedData<FlashSaleAdminItem>> {
return get<PaginatedData<FlashSaleAdminItem>>('/admin/flash-sales', params as Record<string, unknown>)
}
async function createFlashSale(dto: CreateFlashSaleDto): Promise<FlashSaleAdminItem> {
return post<FlashSaleAdminItem>('/admin/flash-sales', dto as unknown as Record<string, unknown>)
}
async function updateFlashSale(id: string, dto: UpdateFlashSaleDto): Promise<FlashSaleAdminItem> {
return put<FlashSaleAdminItem>(`/admin/flash-sales/${id}`, dto as unknown as Record<string, unknown>)
}
async function deleteFlashSale(id: string): Promise<{ deleted: boolean }> {
return del<{ deleted: boolean }>(`/admin/flash-sales/${id}`)
}
return {
// State
weekTemplates,
cardTypes,
studioConfig,
schedulePreview,
scheduleLoading,
// Week templates
fetchWeekTemplates,
saveWeekTemplates,
// Card types
fetchCardTypes,
createCardType,
@@ -184,12 +268,16 @@ export const useAdminStore = defineStore('admin', () => {
// Studio
fetchStudioConfig,
saveStudioConfig,
createStudioUploadCredential,
// Orders
fetchAdminOrders,
// Bookings
fetchAdminBookings,
// Members
fetchMembers,
getUserMembership,
updateUserMembership,
deleteUserMembership,
// Time slots
fetchSlotsByDate,
createManualSlot,
@@ -200,5 +288,10 @@ export const useAdminStore = defineStore('admin', () => {
publishDaySlots,
// Stats
fetchDashboardStats,
// Flash sales
fetchFlashSales,
createFlashSale,
updateFlashSale,
deleteFlashSale,
}
})

View File

@@ -6,6 +6,7 @@ import type {
BookingWithUser,
BookingStatusHistory,
CreateBookingDto,
TeachingScheduleSlot,
} from '@mp-pilates/shared'
import { get, post, put } from '../utils/request'
@@ -21,8 +22,10 @@ export const useBookingStore = defineStore('booking', () => {
const slots = ref<readonly TimeSlotWithBookingStatus[]>([])
const myBookings = ref<readonly BookingWithDetails[]>([])
const upcomingBookings = ref<readonly BookingWithDetails[]>([])
const teachingSchedule = ref<readonly TeachingScheduleSlot[]>([])
const loadingSlots = ref(false)
const loadingBookings = ref(false)
const loadingTeachingSchedule = ref(false)
async function fetchSlots(date: string) {
loadingSlots.value = true
@@ -70,6 +73,21 @@ export const useBookingStore = defineStore('booking', () => {
}
}
async function fetchTeachingSchedule(date: string) {
loadingTeachingSchedule.value = true
try {
const result = await get<TeachingScheduleSlot[]>('/admin/teaching-schedule', { date })
teachingSchedule.value = Array.isArray(result) ? result : []
return teachingSchedule.value
} catch (err) {
console.error('Fetch teaching schedule failed:', err)
teachingSchedule.value = []
throw err
} finally {
loadingTeachingSchedule.value = false
}
}
// ─── Admin methods ──────────────────────────────────────────────────────
async function fetchAllAdminBookings(
@@ -115,22 +133,31 @@ export const useBookingStore = defineStore('booking', () => {
return result
}
async function fetchSlotById(slotId: string) {
const result = await get<TimeSlotWithBookingStatus>(`/time-slot/${slotId}`)
return result
}
return {
slots,
myBookings,
upcomingBookings,
teachingSchedule,
loadingSlots,
loadingBookings,
loadingTeachingSchedule,
fetchSlots,
createBooking,
cancelBooking,
fetchMyBookings,
fetchUpcomingBookings,
fetchTeachingSchedule,
fetchAllAdminBookings,
confirmBooking,
completeBooking,
markNoShow,
fetchBookingHistory,
fetchSlotById,
fetchBookingById,
}
})

View File

@@ -0,0 +1,43 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type {
FlashSaleListItem,
FlashSaleDetail,
FlashSalePurchaseResponse,
} from '@mp-pilates/shared'
import { get, post } from '../utils/request'
export const useFlashSaleStore = defineStore('flash-sale', () => {
const flashSales = ref<FlashSaleListItem[]>([])
const loading = ref(false)
async function fetchFlashSales(): Promise<FlashSaleListItem[]> {
loading.value = true
try {
const data = await get<FlashSaleListItem[]>('/flash-sales')
flashSales.value = [...data]
return data
} catch {
flashSales.value = []
return []
} finally {
loading.value = false
}
}
async function fetchDetail(id: string): Promise<FlashSaleDetail> {
return get<FlashSaleDetail>(`/flash-sales/${id}`)
}
async function purchase(id: string): Promise<FlashSalePurchaseResponse> {
return post<FlashSalePurchaseResponse>(`/flash-sales/${id}/purchase`)
}
return {
flashSales,
loading,
fetchFlashSales,
fetchDetail,
purchase,
}
})

View File

@@ -0,0 +1,26 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { InviteActivitySummary } from '@mp-pilates/shared'
import { get } from '../utils/request'
export const useInviteStore = defineStore('invite', () => {
const activity = ref<InviteActivitySummary | null>(null)
const loading = ref(false)
async function fetchActivity() {
loading.value = true
try {
activity.value = await get<InviteActivitySummary>('/invite/activity')
return activity.value
} finally {
loading.value = false
}
}
return {
activity,
loading,
fetchActivity,
}
})

View File

@@ -8,6 +8,12 @@ import type {
import { UserRole, MembershipStatus } from '@mp-pilates/shared'
import { wxLogin, isLoggedIn, logout as authLogout } from '../utils/auth'
import { get, put } from '../utils/request'
import { ROUTES } from '../utils/routes'
import { cacheSubscriptionMessageTemplateConfig, resetSubscriptionMessageTemplateCache } from '../utils/wechat-subscription'
function syncSubscriptionTemplates(profile?: Pick<UserProfileResponse, 'subscriptionMessageTemplates'> | null) {
cacheSubscriptionMessageTemplateConfig(profile?.subscriptionMessageTemplates)
}
export const useUserStore = defineStore('user', () => {
// State
@@ -26,6 +32,7 @@ export const useUserStore = defineStore('user', () => {
memberships.value.filter((m) => m.status === MembershipStatus.ACTIVE),
)
const hasValidMembership = computed(() => activeMemberships.value.length > 0)
const inviteShareEligible = computed(() => !!user.value?.inviteShareEligible)
// Actions
async function login() {
@@ -33,17 +40,34 @@ export const useUserStore = defineStore('user', () => {
const result = await wxLogin()
token.value = result.token
user.value = result.user
return result.user
syncSubscriptionTemplates(result.user)
return { user: result.user, isNewUser: result.isNewUser }
} catch (err) {
console.error('Login failed:', err)
throw err
}
}
/**
* Login, redirect new users to complete profile, and fetch post-login data.
* Returns isNewUser so callers know whether navigation was intercepted.
*/
async function loginWithSetup(): Promise<{ isNewUser: boolean }> {
const { isNewUser } = await login()
if (isNewUser) {
uni.navigateTo({ url: ROUTES.PROFILE_INFO_FIRST_LOGIN })
return { isNewUser: true }
}
await Promise.all([fetchProfile(), fetchMemberships()])
return { isNewUser: false }
}
async function fetchProfile() {
if (!isLoggedIn()) return
try {
user.value = await get<UserProfileResponse>('/user/profile')
syncSubscriptionTemplates(user.value)
return user.value
} catch (err) {
console.error('Fetch profile failed:', err)
}
@@ -70,9 +94,15 @@ export const useUserStore = defineStore('user', () => {
async function updateProfile(data: { nickname?: string; avatarUrl?: string }) {
const updated = await put<UserProfileResponse>('/user/profile', data)
user.value = updated
syncSubscriptionTemplates(updated)
return updated
}
function setProfile(profile: UserProfileResponse) {
user.value = profile
syncSubscriptionTemplates(profile)
}
function checkAuth() {
if (isLoggedIn()) {
fetchProfile()
@@ -82,6 +112,7 @@ export const useUserStore = defineStore('user', () => {
function logout() {
authLogout()
resetSubscriptionMessageTemplateCache()
token.value = ''
user.value = null
stats.value = null
@@ -98,11 +129,14 @@ export const useUserStore = defineStore('user', () => {
isAdmin,
activeMemberships,
hasValidMembership,
inviteShareEligible,
login,
loginWithSetup,
fetchProfile,
fetchStats,
fetchMemberships,
updateProfile,
setProfile,
checkAuth,
logout,
}

View File

@@ -4,40 +4,82 @@ import type { UserProfileResponse } from '@mp-pilates/shared'
interface LoginResponse {
readonly token: string
readonly user: UserProfileResponse
readonly isNewUser: boolean
}
interface UniErrorLike {
readonly errMsg?: string
}
interface WechatPrivacyApi {
requirePrivacyAuthorize?: (options: {
success: () => void
fail: (err: UniErrorLike) => void
}) => void
}
function buildPrivacyError(err?: UniErrorLike): Error {
const errMsg = err?.errMsg || ''
if (errMsg.includes('cancel') || errMsg.includes('deny') || errMsg.includes('disagree')) {
return new Error('请先同意隐私保护指引')
}
return new Error('隐私授权失败,请重试')
}
async function ensurePrivacyAuthorization(): Promise<void> {
// #ifdef MP-WEIXIN
const wechat = (globalThis as typeof globalThis & { wx?: WechatPrivacyApi }).wx
if (!wechat || typeof wechat.requirePrivacyAuthorize !== 'function') {
return
}
const requirePrivacyAuthorize = wechat.requirePrivacyAuthorize
await new Promise<void>((resolve, reject) => {
requirePrivacyAuthorize({
success: () => resolve(),
fail: (err: UniErrorLike) => reject(buildPrivacyError(err)),
})
})
// #endif
}
export function getErrorMessage(err: unknown, fallback: string): string {
if (err instanceof Error && err.message) {
return err.message
}
return fallback
}
export async function wxLogin(): Promise<LoginResponse> {
const inviterId = uni.getStorageSync('invite_inviter_id') as string
await ensurePrivacyAuthorization()
return new Promise((resolve, reject) => {
// Step 1:静默登录,获取 code
uni.login({
provider: 'weixin',
success: async (loginRes) => {
try {
// Step 2: 获取用户微信头像和昵称
let nickname: string | undefined
let avatarUrl: string | undefined
await new Promise<void>((res) => {
uni.getUserProfile({
desc: '用于完善个人资料',
success: (profileRes) => {
nickname = profileRes.userInfo.nickName
avatarUrl = profileRes.userInfo.avatarUrl
res()
},
fail: () => {
// 用户拒绝授权,仍可继续登录
res()
},
})
})
if (!loginRes.code) {
reject(new Error('微信登录失败,请重试'))
return
}
// Step 3: 发送登录请求
// Step 2: 发送登录请求
// 注uni.getUserProfile 已被微信废弃(基础库 2.27.1+
// 新用户的昵称/头像由后端生成默认值,用户可在个人资料页修改
const result = await post<LoginResponse>('/auth/login', {
code: loginRes.code,
nickname,
avatarUrl,
inviterId: inviterId || undefined,
})
uni.setStorageSync('token', result.token)
if (result.isNewUser && inviterId) {
uni.removeStorageSync('invite_inviter_id')
}
resolve(result)
} catch (err) {
reject(err)

View File

@@ -1,3 +1,12 @@
import { CardTypeCategory } from '@mp-pilates/shared'
import { FlashSalePhase } from '@mp-pilates/shared'
/** Minimal membership shape needed by progress/usage helpers. */
interface MembershipLike {
readonly remainingTimes: number | null
readonly cardType: { readonly totalTimes: number | null }
}
/** 格式化金额:分 → 元 */
export function formatPrice(cents: number): string {
return (cents / 100).toFixed(2)
@@ -44,3 +53,105 @@ export function getDateRange(days: number): ReadonlyArray<{ readonly date: strin
}
return result
}
/** 会员卡类型标签 */
export function getCardTypeLabel(type: CardTypeCategory | string): string {
const map: Record<string, 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'
}
/** 判断课程时段是否已过期(当前时间 > 课程开始时间) */
export function isSlotPast(date: string, startTime: string): boolean {
const slotDateTime = new Date(`${date}T${startTime}:00`)
return new Date() > slotDateTime
}
/** 会员卡渐变 CSS 类名前缀 */
export function getCardGradientClass(type: CardTypeCategory | string): string {
if (type === CardTypeCategory.DURATION) return 'gradient--duration'
if (type === CardTypeCategory.TRIAL) return 'gradient--trial'
return 'gradient--times'
}
/** 会员卡进度百分比(剩余 / 总次数clamp 到 0~100% */
export function getMembershipProgressWidth(membership: MembershipLike): string {
if (membership.remainingTimes === null || !membership.cardType.totalTimes) return '0%'
const pct = (membership.remainingTimes / membership.cardType.totalTimes) * 100
return `${Math.max(0, Math.min(100, pct))}%`
}
/** 已使用次数(不低于 0防止管理员调高剩余次数导致负值 */
export function getMembershipUsedTimes(membership: MembershipLike): number {
if (membership.remainingTimes === null || !membership.cardType.totalTimes) return 0
return Math.max(0, membership.cardType.totalTimes - membership.remainingTimes)
}
/** 格式化倒计时HH:MM:SS */
export function formatCountdown(targetTime: string): string {
const { h, m, s } = getCountdownParts(targetTime)
return `${h}:${m}:${s}`
}
/** 获取倒计时各部分h/m/s 已补零) */
export function getCountdownParts(targetTime: string): { readonly h: string; readonly m: string; readonly s: string } {
const diff = Math.max(0, new Date(targetTime).getTime() - Date.now())
return {
h: String(Math.floor(diff / 3_600_000)).padStart(2, '0'),
m: String(Math.floor((diff % 3_600_000) / 60_000)).padStart(2, '0'),
s: String(Math.floor((diff % 60_000) / 1000)).padStart(2, '0'),
}
}
/** 秒杀阶段中文标签 */
export function getFlashSalePhaseLabel(phase: FlashSalePhase): string {
const map: Record<FlashSalePhase, string> = {
[FlashSalePhase.UPCOMING]: '即将开始',
[FlashSalePhase.ONGOING]: '抢购中',
[FlashSalePhase.SOLD_OUT]: '已售罄',
[FlashSalePhase.ENDED]: '已结束',
}
return map[phase]
}
/** 库存已售比例 */
export function getStockRatio(soldCount: number, totalStock: number): number {
if (totalStock === 0) return 0
return soldCount / totalStock
}
/** 库存已售百分比字符串 */
export function getStockPercent(soldCount: number, totalStock: number): string {
return `${Math.min(100, getStockRatio(soldCount, totalStock) * 100)}%`
}
/** 格式化日期时间为 MM-DD HH:mm:ss */
export function formatDateTime(dateStr: string): string {
const d = new Date(dateStr)
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hour = String(d.getHours()).padStart(2, '0')
const min = String(d.getMinutes()).padStart(2, '0')
const sec = String(d.getSeconds()).padStart(2, '0')
return `${month}-${day} ${hour}:${min}:${sec}`
}
/** 格式化 Date 为 YYYY-MM-DD本地时间用于 picker */
export function formatDateLocal(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
/** 格式化 Date 为 HH:MM本地时间用于 picker */
export function formatTimeLocal(d: Date): string {
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}

View File

@@ -1,16 +1,7 @@
import type { ApiResponse, PaginatedData } from '@mp-pilates/shared'
const BASE_URL = (() => {
try {
const { miniProgram } = uni.getAccountInfoSync()
if (miniProgram.envVersion !== 'develop') {
return 'https://focus.richarjiang.com/api'
}
} catch {
// 非小程序环境,使用开发地址
}
return 'http://localhost:3000/api'
})()
// 统一使用线上服务地址
const BASE_URL = 'https://focus.richarjiang.com/api'
interface RequestOptions {
readonly url: string

View File

@@ -0,0 +1,4 @@
export const ROUTES = {
PROFILE_INFO: '/pages/profile/info',
PROFILE_INFO_FIRST_LOGIN: '/pages/profile/info?from=login',
} as const

View File

@@ -0,0 +1,72 @@
import type {
CreateStudioUploadCredentialDto,
StudioAssetType,
StudioUploadCredential,
} from '@mp-pilates/shared'
import type { useAdminStore } from '../stores/admin'
type AdminStore = ReturnType<typeof useAdminStore>
function inferContentType(fileName: string): string | undefined {
const extension = fileName.split('.').pop()?.toLowerCase()
if (extension === 'jpg' || extension === 'jpeg') {
return 'image/jpeg'
}
if (extension === 'png') {
return 'image/png'
}
if (extension === 'webp') {
return 'image/webp'
}
if (extension === 'heic') {
return 'image/heic'
}
if (extension === 'heif') {
return 'image/heif'
}
return undefined
}
function uploadToCos(filePath: string, credential: StudioUploadCredential): Promise<void> {
return new Promise((resolve, reject) => {
uni.uploadFile({
url: credential.uploadUrl,
filePath,
name: 'file',
formData: credential.formData as unknown as Record<string, string>,
success: (result) => {
if (result.statusCode >= 200 && result.statusCode < 300) {
resolve()
return
}
const body = typeof result.data === 'string' ? result.data : JSON.stringify(result.data)
const code = body.match(/<Code>([^<]+)<\/Code>/)?.[1]
const message = body.match(/<Message>([^<]+)<\/Message>/)?.[1]
const detail = code || message ? `${code ?? 'COS'}: ${message ?? body}` : body
reject(new Error(`COS 上传失败 (${result.statusCode}) ${detail}`))
},
fail: (error) => {
reject(new Error(error.errMsg || 'COS 上传失败'))
},
})
})
}
export async function uploadStudioAsset(params: {
adminStore: AdminStore
filePath: string
fileName: string
assetType: StudioAssetType
}): Promise<string> {
const payload: CreateStudioUploadCredentialDto = {
fileName: params.fileName,
contentType: inferContentType(params.fileName),
assetType: params.assetType,
}
const credential = await params.adminStore.createStudioUploadCredential(payload)
await uploadToCos(params.filePath, credential)
return credential.fileUrl
}

View File

@@ -0,0 +1,62 @@
export interface TeacherProfile {
id: string
name: string
title: string
avatar: string
cover: string
badges: string[]
specialties: string[]
intro: string
certifications: string[]
teachingFocus: Array<{ title: string; desc: string }>
stats: Array<{ label: string; value: string }>
}
export const irisProfile: TeacherProfile = {
id: 'iris',
name: 'Iris',
title: '高级普拉提教练',
avatar: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/images/teacher_avatar.jpg',
cover: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/images/teacher_avatar.jpg',
badges: ['高级', 'STOTT PILATES'],
specialties: ['塑性训练', '体态调整', '产后恢复'],
intro: '我擅长把专业训练拆解成身体真正听得懂的节奏,让你在安全、稳定与被看见的陪伴里,一点点找回力量、线条与自信。',
certifications: [
'斯多特塑身机初&中级认证教练',
'斯多特全场馆认证教练',
'曼丽丘斯多特塑身机弹跳网芭杆工作坊',
'塑身机上的高尔夫与旋转运动调理工作坊',
'四维人体运动解剖认证',
],
teachingFocus: [
{
title: '核心激活',
desc: '从骨盆与深层腹部控制入手,建立稳定发力基础。',
},
{
title: '体态调整',
desc: '针对圆肩、头前伸、骨盆前倾等常见问题做精细训练。',
},
{
title: '产后恢复',
desc: '循序渐进关注核心重建、呼吸配合与身体觉知恢复。',
},
{
title: '塑形训练',
desc: '兼顾力量与线条,让臀腿、背部和核心更有支撑感。',
},
{
title: '呼吸控制',
desc: '把呼吸节奏融入动作,提高训练效率与身体连接感。',
},
{
title: '一对一进阶',
desc: '根据训练基础与目标安排节奏,适合长期稳定提升。',
},
],
stats: [
{ label: '擅长方向', value: '塑形 / 体态' },
{ label: '授课类型', value: '私教为主' },
{ label: '课程时长', value: '60 分钟' },
],
}

View File

@@ -0,0 +1,256 @@
import {
SubscriptionMessageScene,
} from '@mp-pilates/shared'
import type {
ReportSubscriptionMessageRequestDto,
SubscriptionMessageRequestItem,
SubscriptionMessageTemplate,
SubscriptionMessageTemplateConfig,
UserProfileResponse,
} from '@mp-pilates/shared'
import { post } from './request'
type TemplateResult = SubscriptionMessageRequestItem['result'] | 'tmplIds empty' | 'err' | 'undefined'
type RequestSubscribeMessageSuccess = Record<string, TemplateResult | undefined> & {
errMsg?: string
}
interface RequestSubscribeMessageFail {
errMsg?: string
errCode?: number | null
}
function stringifyDebugPayload(payload: unknown): string {
try {
return JSON.stringify(payload)
} catch {
return String(payload)
}
}
function getSubscribeDebugContext() {
try {
const windowInfo = typeof uni.getWindowInfo === 'function' ? uni.getWindowInfo() : null
const appBaseInfo = typeof uni.getAppBaseInfo === 'function' ? uni.getAppBaseInfo() : null
const deviceInfo = typeof uni.getDeviceInfo === 'function' ? uni.getDeviceInfo() : null
const host = appBaseInfo?.host as { env?: string } | string | undefined
return {
platform: deviceInfo?.platform ?? 'unknown',
hostEnv: typeof host === 'object' && host ? host.env : undefined,
app: appBaseInfo?.appName,
system: deviceInfo?.system,
language: appBaseInfo?.language,
version: appBaseInfo?.version,
SDKVersion: appBaseInfo?.SDKVersion,
windowWidth: windowInfo?.windowWidth,
}
} catch {
return {
platform: 'unknown',
}
}
}
function buildSubscribeError(err: RequestSubscribeMessageFail, scene: SubscriptionMessageScene, templateIds: string[]): Error {
const debugContext = getSubscribeDebugContext()
const rawMessage = (err.errMsg || '').trim()
if (!rawMessage && debugContext.platform === 'devtools') {
return new Error(
`开发者工具当前环境不支持订阅消息唤起。请退出游客模式,使用已登录的微信开发者工具并在真机中重试。调试信息: ${stringifyDebugPayload({ scene, templateIds, err, debugContext })}`,
)
}
return new Error(
`订阅消息授权失败: ${stringifyDebugPayload({ scene, templateIds, err, debugContext })}`,
)
}
const TEMPLATE_CONFIG_STORAGE_KEY = 'subscriptionMessageTemplateConfig'
let cachedConfig: SubscriptionMessageTemplateConfig | null = null
function isMpWeixin(): boolean {
// #ifdef MP-WEIXIN
return true
// #endif
return false
}
function normalizeResult(result?: TemplateResult): SubscriptionMessageRequestItem['result'] | null {
if (result === 'accept' || result === 'reject' || result === 'ban' || result === 'filter') {
return result
}
return null
}
async function fetchTemplateConfig(): Promise<SubscriptionMessageTemplateConfig> {
if (cachedConfig) {
return cachedConfig
}
const stored = uni.getStorageSync(TEMPLATE_CONFIG_STORAGE_KEY) as SubscriptionMessageTemplateConfig | ''
if (!stored || !Array.isArray(stored.templates)) {
throw new Error('订阅消息模板尚未初始化,请重新进入页面后重试')
}
const config: SubscriptionMessageTemplateConfig = {
templates: stored.templates.filter((item) => item.templateId),
}
cachedConfig = config
return config
}
function normalizeTemplateConfig(config?: Partial<SubscriptionMessageTemplateConfig> | null): SubscriptionMessageTemplateConfig {
const templates = Array.isArray(config?.templates) ? config.templates : []
return {
templates: templates.filter((item): item is SubscriptionMessageTemplate => !!item?.templateId),
}
}
export function cacheSubscriptionMessageTemplateConfig(
config?: Partial<SubscriptionMessageTemplateConfig> | null,
): SubscriptionMessageTemplateConfig {
const normalized = normalizeTemplateConfig(config)
cachedConfig = normalized
uni.setStorageSync(TEMPLATE_CONFIG_STORAGE_KEY, normalized)
return normalized
}
function getTemplatesByScene(
config: SubscriptionMessageTemplateConfig,
scene: SubscriptionMessageScene,
): SubscriptionMessageTemplate[] {
return config.templates.filter((item) => item.scene === scene && item.templateId)
}
async function reportResults(requests: SubscriptionMessageRequestItem[]): Promise<void> {
if (requests.length === 0) {
return
}
const payload: ReportSubscriptionMessageRequestDto = { requests }
await post('/user/subscription-messages/report', payload as unknown as Record<string, unknown>)
}
export async function requestSubscriptionMessage(scene: SubscriptionMessageScene): Promise<SubscriptionMessageRequestItem[]> {
if (!isMpWeixin()) {
return []
}
const config = await fetchTemplateConfig()
const templates = getTemplatesByScene(config, scene)
if (templates.length === 0) {
console.error('[subscribe] no templates matched scene', stringifyDebugPayload({ scene, config, debugContext: getSubscribeDebugContext() }))
return []
}
const templateIds = templates.map((item) => item.templateId)
const debugContext = getSubscribeDebugContext()
console.log('[subscribe] requestSubscribeMessage:start', stringifyDebugPayload({ scene, templateIds, templates, debugContext }))
const result = await new Promise<RequestSubscribeMessageSuccess>((resolve, reject) => {
uni.requestSubscribeMessage({
tmplIds: templateIds,
success: (res) => {
console.log('[subscribe] requestSubscribeMessage:success', stringifyDebugPayload({ scene, response: res, templateIds, debugContext }))
resolve(res as RequestSubscribeMessageSuccess)
},
fail: (err) => {
console.error('[subscribe] requestSubscribeMessage:fail', stringifyDebugPayload({ scene, error: err, templateIds, debugContext }))
reject(buildSubscribeError(err as RequestSubscribeMessageFail, scene, templateIds))
},
})
})
const requests = templates
.map<SubscriptionMessageRequestItem | null>((item) => {
const normalized = normalizeResult(result[item.templateId])
if (!normalized) {
return null
}
return {
templateId: item.templateId,
scene: item.scene,
result: normalized,
}
})
.filter((item): item is SubscriptionMessageRequestItem => item !== null)
console.log('[subscribe] requestSubscribeMessage:normalized', stringifyDebugPayload({ scene, result, requests, templateIds, debugContext }))
await reportResults(requests)
return requests
}
export async function requestOrderPaidSubscriptionMessage(): Promise<SubscriptionMessageRequestItem[]> {
return requestSubscriptionMessage(SubscriptionMessageScene.BOOKING_CREATED)
}
export async function requestBookingCreatedSubscriptionMessage(): Promise<SubscriptionMessageRequestItem[]> {
return requestSubscriptionMessage(SubscriptionMessageScene.BOOKING_CREATED)
}
export async function requestAdminBookingSubscriptionCount(): Promise<UserProfileResponse | null> {
if (!isMpWeixin()) {
return null
}
const config = await fetchTemplateConfig()
const templates = getTemplatesByScene(config, SubscriptionMessageScene.ADMIN_BOOKING_CREATED)
.filter((item) => item.usageTarget === 'counter')
if (templates.length === 0) {
console.error('[subscribe] no admin counter template configured', stringifyDebugPayload({
scene: SubscriptionMessageScene.ADMIN_BOOKING_CREATED,
config,
debugContext: getSubscribeDebugContext(),
}))
return null
}
const templateId = templates[0].templateId
const debugContext = getSubscribeDebugContext()
console.log('[subscribe] requestSubscribeMessage:adminCounter:start', stringifyDebugPayload({
templateId,
debugContext,
}))
const result = await new Promise<RequestSubscribeMessageSuccess>((resolve, reject) => {
uni.requestSubscribeMessage({
tmplIds: [templateId],
success: (res) => {
console.log('[subscribe] requestSubscribeMessage:adminCounter:success', stringifyDebugPayload({ response: res, templateId, debugContext }))
resolve(res as RequestSubscribeMessageSuccess)
},
fail: (err) => {
console.error('[subscribe] requestSubscribeMessage:adminCounter:fail', stringifyDebugPayload({ error: err, templateId, debugContext }))
reject(buildSubscribeError(err as RequestSubscribeMessageFail, SubscriptionMessageScene.ADMIN_BOOKING_CREATED, [templateId]))
},
})
})
const normalized = normalizeResult(result[templateId])
console.log('[subscribe] requestSubscribeMessage:adminCounter:normalized', stringifyDebugPayload({
templateId,
normalized,
rawResult: result,
debugContext,
}))
if (normalized !== 'accept') {
return null
}
return post<UserProfileResponse>('/user/subscription-messages/admin-booking-count')
}
export function resetSubscriptionMessageTemplateCache(): void {
cachedConfig = null
uni.removeStorageSync(TEMPLATE_CONFIG_STORAGE_KEY)
}

35
packages/server/.env Normal file
View File

@@ -0,0 +1,35 @@
# Database
DATABASE_URL=mysql://root:AK8jyLfsfMA5wNdC@129.204.155.94:13306/db_mp_focus
# JWT
JWT_SECRET=change-me-to-a-secure-random-string
# WeChat Mini Program
WX_APPID=wx3e7a133d2305fa2c
WX_SECRET=92f4f91af72ca0705d65e39e605cb98b
# WeChat Pay
WX_MCH_ID=1110530023
WX_MCH_KEY=ACbGcH3FNLBacmvmIVR4uWXjNf9h8jQ2
WX_MCH_SERIAL_NO=7A90D96A7ED1A129E98DB5FD5F3A84EDC34B2AC6
WX_MCH_KEY_PATH=./certs/apiclient_key.pem
# API Base URL (used for WeChat Pay callback notification)
API_BASE_URL=https://focus.richarjiang.com/
# Server
PORT=3000
WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED=antYfc85gvwImFZ9kM4UiqMOywJxbqFVgKHLH3NikII
# COS upload
COS_SECRET_ID=AKIDwwulT3ub9f9bxFVdihcP4Z1S6qivMxmu
COS_SECRET_KEY=S1rrw0CY1fRQj7X7fCpjryAMwgel6drG
COS_BUCKET=plates-1251306435
COS_REGION=ap-guangzhou
COS_UPLOAD_ROLE_ARN=qcs::cam::uin/649581473:roleName/MpPilatesCosUploadRole
COS_APP_ID=1251306435
COS_PUBLIC_BASE_URL=https://plates-1251306435.cos.ap-guangzhou.myqcloud.com
COS_UPLOAD_PREFIX=mp/studio
COS_UPLOAD_DURATION_SECONDS=1800
COS_UPLOAD_ROLE_SESSION_NAME=mp-pilates-studio-upload

View File

@@ -0,0 +1,13 @@
DATABASE_URL=mysql://user:password@127.0.0.1:3306/mp_pilates
JWT_SECRET=change-me
WX_APPID=your-wechat-appid
WX_SECRET=your-wechat-secret
# COS upload
COS_SECRET_ID=your-cos-secret-id
COS_SECRET_KEY=your-cos-secret-key
COS_BUCKET=plates-1251306435
COS_REGION=ap-guangzhou
COS_PUBLIC_BASE_URL=https://plates-1251306435.cos.ap-guangzhou.myqcloud.com
COS_UPLOAD_PREFIX=mp/studio
COS_UPLOAD_DURATION_SECONDS=1800

View File

@@ -13,6 +13,7 @@
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:seed": "ts-node prisma/seed.ts",
"studio:seed-gallery": "ts-node prisma/update-studio-gallery.ts",
"lint": "eslint \"{src,test}/**/*.ts\""
},
"dependencies": {
@@ -52,9 +53,9 @@
},
"jest": {
"moduleFileExtensions": [
"ts",
"js",
"json",
"ts"
"json"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
@@ -69,6 +70,7 @@
"coverageDirectory": "../coverage",
"testEnvironment": "node",
"moduleNameMapper": {
"^@mp-pilates/shared$": "<rootDir>/../../shared/src/index.ts",
"^@mp-pilates/shared(.*)$": "<rootDir>/../../shared/src$1"
}
},

View File

@@ -51,26 +51,75 @@ enum OrderStatus {
REFUNDED
}
enum FlashSaleStatus {
DRAFT
ACTIVE
ENDED
}
enum FlashSaleOrderStatus {
RESERVED
PAID
EXPIRED
}
enum InviteReferralStatus {
REGISTERED
TRIAL_PURCHASED
QUALIFIED
}
// ===== Models =====
model User {
id String @id @default(uuid())
openid String @unique
unionid String?
phone String?
nickname String @default("")
avatarUrl String? @map("avatar_url")
role UserRole @default(MEMBER)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
id String @id @default(uuid())
openid String @unique
unionid String?
phone String?
nickname String @default("")
avatarUrl String? @map("avatar_url")
role UserRole @default(MEMBER)
adminBookingSubscriptionCount Int @default(0) @map("admin_booking_subscription_count")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
memberships Membership[]
bookings Booking[]
orders Order[]
flashSaleOrders FlashSaleOrder[]
subscriptionMessageConsents SubscriptionMessageConsent[]
sentInviteReferrals InviteReferral[] @relation("InviteReferralInviter")
receivedInviteReferral InviteReferral[] @relation("InviteReferralInvitee")
inviteRewardGrants InviteRewardGrant[] @relation("InviteRewardGrantInviter")
@@map("users")
}
model SubscriptionMessageConsent {
id String @id @default(uuid())
userId String @map("user_id")
templateId String @map("template_id")
scene String
totalRequestCount Int @default(0) @map("total_request_count")
acceptCount Int @default(0) @map("accept_count")
rejectCount Int @default(0) @map("reject_count")
banCount Int @default(0) @map("ban_count")
filterCount Int @default(0) @map("filter_count")
sentCount Int @default(0) @map("sent_count")
lastResult String @map("last_result")
lastRequestedAt DateTime @default(now()) @map("last_requested_at")
lastSentAt DateTime? @map("last_sent_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id])
@@unique([userId, templateId, scene])
@@index([userId])
@@index([scene])
@@map("subscription_message_consents")
}
model CardType {
id String @id @default(uuid())
name String
@@ -80,6 +129,7 @@ model CardType {
price Decimal @db.Decimal(10, 0)
originalPrice Decimal? @map("original_price") @db.Decimal(10, 0)
description String?
coverUrl String? @map("cover_url")
isActive Boolean @default(true) @map("is_active")
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at")
@@ -87,6 +137,7 @@ model CardType {
memberships Membership[]
orders Order[]
flashSales FlashSale[]
@@map("card_types")
}
@@ -105,6 +156,7 @@ model Membership {
user User @relation(fields: [userId], references: [id])
cardType CardType @relation(fields: [cardTypeId], references: [id])
bookings Booking[]
inviteRewardGrants InviteRewardGrant[]
@@index([userId])
@@index([status])
@@ -164,6 +216,7 @@ model Booking {
user User @relation(fields: [userId], references: [id])
timeSlot TimeSlot @relation(fields: [timeSlotId], references: [id])
membership Membership @relation(fields: [membershipId], references: [id])
qualifiedInviteReferrals InviteReferral[]
statusHistory BookingStatusHistory[]
@@ -197,17 +250,61 @@ model Order {
status OrderStatus @default(PENDING)
wxTransactionId String? @map("wx_transaction_id")
paidAt DateTime? @map("paid_at")
flashSaleId String? @map("flash_sale_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id])
cardType CardType @relation(fields: [cardTypeId], references: [id])
user User @relation(fields: [userId], references: [id])
cardType CardType @relation(fields: [cardTypeId], references: [id])
flashSaleOrder FlashSaleOrder?
inviteReferrals InviteReferral[]
@@index([userId])
@@index([status])
@@map("orders")
}
model InviteReferral {
id String @id @default(uuid())
inviterId String @map("inviter_id")
inviteeId String @unique @map("invitee_id")
status InviteReferralStatus @default(REGISTERED)
trialOrderId String? @unique @map("trial_order_id")
qualifiedBookingId String? @unique @map("qualified_booking_id")
invitedAt DateTime @default(now()) @map("invited_at")
trialPurchasedAt DateTime? @map("trial_purchased_at")
qualifiedAt DateTime? @map("qualified_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
inviter User @relation("InviteReferralInviter", fields: [inviterId], references: [id])
invitee User @relation("InviteReferralInvitee", fields: [inviteeId], references: [id])
trialOrder Order? @relation(fields: [trialOrderId], references: [id])
qualifiedBooking Booking? @relation(fields: [qualifiedBookingId], references: [id])
@@unique([inviterId, inviteeId])
@@index([inviterId, status])
@@index([status])
@@map("invite_referrals")
}
model InviteRewardGrant {
id String @id @default(uuid())
inviterId String @map("inviter_id")
membershipId String? @map("membership_id")
qualifiedReferralCount Int @map("qualified_referral_count")
rewardTimes Int @default(1) @map("reward_times")
grantedAt DateTime @default(now()) @map("granted_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
inviter User @relation("InviteRewardGrantInviter", fields: [inviterId], references: [id])
membership Membership? @relation(fields: [membershipId], references: [id])
@@index([inviterId, grantedAt])
@@map("invite_reward_grants")
}
model StudioConfig {
id String @id @default(uuid())
name String
@@ -223,3 +320,48 @@ model StudioConfig {
@@map("studio_config")
}
model FlashSale {
id String @id @default(uuid())
cardTypeId String @map("card_type_id")
title String
originalPrice Decimal @map("original_price") @db.Decimal(10, 0)
flashPrice Decimal @map("flash_price") @db.Decimal(10, 0)
totalStock Int @map("total_stock")
soldCount Int @default(0) @map("sold_count")
startTime DateTime @map("start_time")
endTime DateTime @map("end_time")
status FlashSaleStatus @default(DRAFT)
description String? @db.Text
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
cardType CardType @relation(fields: [cardTypeId], references: [id])
orders FlashSaleOrder[]
@@index([status, startTime, endTime])
@@map("flash_sales")
}
model FlashSaleOrder {
id String @id @default(uuid())
flashSaleId String @map("flash_sale_id")
userId String @map("user_id")
orderId String? @unique @map("order_id")
status FlashSaleOrderStatus @default(RESERVED)
reservedAt DateTime @default(now()) @map("reserved_at")
paidAt DateTime? @map("paid_at")
expiredAt DateTime? @map("expired_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
flashSale FlashSale @relation(fields: [flashSaleId], references: [id])
user User @relation(fields: [userId], references: [id])
order Order? @relation(fields: [orderId], references: [id])
@@unique([flashSaleId, userId])
@@index([userId])
@@index([status])
@@map("flash_sale_orders")
}

View File

@@ -98,6 +98,7 @@ async function main() {
openid: 'admin_test_openid',
nickname: '教练',
role: UserRole.ADMIN,
adminBookingSubscriptionCount: 0,
},
})
console.log(' ✅ Admin user created')

View File

@@ -0,0 +1,39 @@
import { PrismaClient } from '@prisma/client'
import { DEFAULT_STUDIO_GALLERY_PHOTOS } from '@mp-pilates/shared'
const prisma = new PrismaClient()
async function main() {
console.log('🖼️ Syncing studio gallery photos...')
const photos = [...DEFAULT_STUDIO_GALLERY_PHOTOS]
const existing = await prisma.studioConfig.findFirst({ select: { id: true } })
if (existing) {
await prisma.studioConfig.update({
where: { id: existing.id },
data: { photos },
})
console.log(` ✅ Updated existing studio config with ${photos.length} gallery images`)
} else {
await prisma.studioConfig.create({
data: {
name: '普拉提工作室',
address: '请在管理后台设置地址',
phone: '请在管理后台设置电话',
cancelHoursLimit: 2,
photos,
},
})
console.log(` ✅ Created studio config with ${photos.length} gallery images`)
}
}
main()
.catch((error) => {
console.error('❌ Studio gallery sync failed:', error)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})

View File

@@ -11,6 +11,8 @@ import { BookingModule } from './booking/booking.module'
import { SchedulerModule } from './scheduler/scheduler.module'
import { PaymentModule } from './payment/payment.module'
import { AdminModule } from './admin/admin.module'
import { FlashSaleModule } from './flash-sale/flash-sale.module'
import { InviteModule } from './invite/invite.module'
@Module({
imports: [
@@ -28,6 +30,8 @@ import { AdminModule } from './admin/admin.module'
SchedulerModule,
PaymentModule,
AdminModule,
FlashSaleModule,
InviteModule,
],
controllers: [AppController],
})

View File

@@ -1,10 +1,12 @@
import { Test, TestingModule } from '@nestjs/testing'
import { JwtService } from '@nestjs/jwt'
import { UnauthorizedException } from '@nestjs/common'
import { UserRole } from '@mp-pilates/shared'
import { MembershipStatus, UserRole } from '@mp-pilates/shared'
import { ConfigService } from '@nestjs/config'
import { AuthService, RANDOM_FN_TOKEN } from '../auth.service'
import { WechatService } from '../wechat.service'
import { PrismaService } from '../../prisma/prisma.service'
import { InviteService } from '../../invite/invite.service'
// ─── Fixtures ────────────────────────────────────────────────────────────────
@@ -22,6 +24,7 @@ const mockUser = {
nickname: TEST_NICKNAME,
avatarUrl: null,
role: UserRole.MEMBER,
adminBookingSubscriptionCount: 0,
createdAt: new Date(),
updatedAt: new Date(),
}
@@ -29,6 +32,9 @@ const mockUser = {
// ─── Mocks ───────────────────────────────────────────────────────────────────
const mockPrismaService = {
membership: {
count: jest.fn(),
},
user: {
findUnique: jest.fn(),
findUniqueOrThrow: jest.fn(),
@@ -46,6 +52,14 @@ const mockJwtService = {
sign: jest.fn(),
}
const mockInviteService = {
bindInviterToUser: jest.fn(),
}
const mockConfigService = {
get: jest.fn(),
}
// ─── Tests ───────────────────────────────────────────────────────────────────
describe('AuthService', () => {
@@ -58,6 +72,8 @@ describe('AuthService', () => {
{ provide: PrismaService, useValue: mockPrismaService },
{ provide: WechatService, useValue: mockWechatService },
{ provide: JwtService, useValue: mockJwtService },
{ provide: InviteService, useValue: mockInviteService },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: RANDOM_FN_TOKEN, useValue: () => 0 }, // deterministic nickname
],
}).compile()
@@ -66,6 +82,8 @@ describe('AuthService', () => {
jest.clearAllMocks()
mockJwtService.sign.mockReturnValue(JWT_TOKEN)
mockPrismaService.membership.count.mockResolvedValue(0)
mockConfigService.get.mockReturnValue('tmpl-booking-confirmed')
})
// ── login ──────────────────────────────────────────────────────────────────
@@ -91,9 +109,30 @@ describe('AuthService', () => {
where: { openid: OPENID },
})
expect(mockPrismaService.user.create).toHaveBeenCalledWith({
data: { openid: OPENID, nickname: TEST_NICKNAME },
data: { openid: OPENID, nickname: TEST_NICKNAME, adminBookingSubscriptionCount: 0 },
})
expect(result.user).toEqual(mockUser)
expect(result.user).toEqual(expect.objectContaining({
id: mockUser.id,
phone: mockUser.phone,
nickname: mockUser.nickname,
avatarUrl: mockUser.avatarUrl,
role: mockUser.role,
activeMembershipCount: 0,
inviteShareEligible: false,
adminBookingSubscriptionCount: 0,
}))
expect(result.user.subscriptionMessageTemplates.templates).toHaveLength(2)
expect(result.isNewUser).toBe(true)
expect(mockInviteService.bindInviterToUser).toHaveBeenCalledWith(USER_ID, undefined)
})
it('binds inviter for new users when inviterId is present', async () => {
mockPrismaService.user.findUnique.mockResolvedValue(null)
mockPrismaService.user.create.mockResolvedValue(mockUser)
await authService.login(loginCode, undefined, undefined, 'inviter-001')
expect(mockInviteService.bindInviterToUser).toHaveBeenCalledWith(USER_ID, 'inviter-001')
})
it('creates user with unionid when present', async () => {
@@ -109,7 +148,7 @@ describe('AuthService', () => {
await authService.login(loginCode)
expect(mockPrismaService.user.create).toHaveBeenCalledWith({
data: { openid: OPENID, unionid, nickname: TEST_NICKNAME },
data: { openid: OPENID, unionid, nickname: TEST_NICKNAME, adminBookingSubscriptionCount: 0 },
})
})
@@ -122,7 +161,12 @@ describe('AuthService', () => {
where: { openid: OPENID },
})
expect(mockPrismaService.user.create).not.toHaveBeenCalled()
expect(result.user).toEqual(mockUser)
expect(result.user).toEqual(expect.objectContaining({
id: mockUser.id,
nickname: mockUser.nickname,
role: mockUser.role,
}))
expect(result.isNewUser).toBe(false)
})
it('returns a valid JWT token', async () => {
@@ -142,10 +186,35 @@ describe('AuthService', () => {
const result = await authService.login(loginCode)
expect(result).toEqual({
expect(result).toEqual(expect.objectContaining({
token: JWT_TOKEN,
user: mockUser,
isNewUser: false,
}))
expect(result.user).toEqual(expect.objectContaining({
id: mockUser.id,
subscriptionMessageTemplates: {
templates: [
expect.objectContaining({ scene: 'BOOKING_CREATED' }),
expect.objectContaining({ scene: 'ADMIN_BOOKING_CREATED' }),
],
},
}))
})
it('includes active membership count and invite eligibility in login response', async () => {
mockPrismaService.user.findUnique.mockResolvedValue(mockUser)
mockPrismaService.membership.count.mockResolvedValue(2)
const result = await authService.login(loginCode)
expect(mockPrismaService.membership.count).toHaveBeenCalledWith({
where: {
userId: USER_ID,
status: MembershipStatus.ACTIVE,
},
})
expect(result.user.activeMembershipCount).toBe(2)
expect(result.user.inviteShareEligible).toBe(true)
})
})

View File

@@ -1,12 +1,13 @@
import {
Controller,
Post,
Body,
UseGuards,
Request,
Controller,
HttpCode,
HttpStatus,
Post,
Request,
UseGuards,
} from '@nestjs/common'
import type { UserProfileResponse } from '@mp-pilates/shared'
import { AuthService } from './auth.service'
import { LoginDto } from './dto/login.dto'
import { BindPhoneDto } from './dto/bind-phone.dto'
@@ -24,11 +25,12 @@ export class AuthController {
@Post('login')
@HttpCode(HttpStatus.OK)
async login(@Body() loginDto: LoginDto): Promise<{ token: string; user: User }> {
async login(@Body() loginDto: LoginDto): Promise<{ token: string; user: UserProfileResponse; isNewUser: boolean }> {
return this.authService.login(
loginDto.code,
loginDto.nickname,
loginDto.avatarUrl,
loginDto.inviterId,
)
}

View File

@@ -2,16 +2,21 @@ import { Module } from '@nestjs/common'
import { PassportModule } from '@nestjs/passport'
import { JwtModule } from '@nestjs/jwt'
import { ConfigModule, ConfigService } from '@nestjs/config'
import { MembershipModule } from '../membership/membership.module'
import { AuthService, RANDOM_FN_TOKEN } from './auth.service'
import { AuthController } from './auth.controller'
import { WechatService } from './wechat.service'
import { JwtStrategy } from './jwt.strategy'
import { JwtAuthGuard } from './jwt-auth.guard'
import { RolesGuard } from './roles.guard'
import { InviteModule } from '../invite/invite.module'
@Module({
imports: [
PassportModule,
InviteModule,
ConfigModule,
MembershipModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],

View File

@@ -1,13 +1,23 @@
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'
import { JwtService } from '@nestjs/jwt'
import { User } from '@prisma/client'
import { UserRole } from '@mp-pilates/shared'
import {
MembershipStatus,
SubscriptionMessageScene,
type SubscriptionMessageTemplate,
type SubscriptionMessageTemplateConfig,
type UserProfileResponse,
UserRole,
} from '@mp-pilates/shared'
import { ConfigService } from '@nestjs/config'
import { PrismaService } from '../prisma/prisma.service'
import { WechatService } from './wechat.service'
import { InviteService } from '../invite/invite.service'
export interface LoginResult {
token: string
user: User
user: UserProfileResponse
isNewUser: boolean
}
export interface JwtPayload {
@@ -54,13 +64,59 @@ export class AuthService {
private readonly prisma: PrismaService,
private readonly jwtService: JwtService,
private readonly wechatService: WechatService,
private readonly inviteService: InviteService,
private readonly configService: ConfigService,
@Inject(RANDOM_FN_TOKEN) private readonly randomFn: () => number = Math.random,
) {}
private buildSubscriptionTemplateConfig(): SubscriptionMessageTemplateConfig {
const templates = [
{
templateId: this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED', ''),
scene: SubscriptionMessageScene.BOOKING_CREATED,
description: '购卡或预约时请求一次订阅,用于后续预约确认通知推送',
usageTarget: 'consent' as const,
},
{
templateId: this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED', ''),
scene: SubscriptionMessageScene.ADMIN_BOOKING_CREATED,
description: '管理员主动增加预约提醒次数,用于接收学员新预约通知',
usageTarget: 'counter' as const,
},
] satisfies SubscriptionMessageTemplate[]
return {
templates: templates.filter((item) => item.templateId),
}
}
private async mapLoginUser(user: User): Promise<UserProfileResponse> {
const activeMembershipCount = await this.prisma.membership.count({
where: {
userId: user.id,
status: MembershipStatus.ACTIVE,
},
})
return {
id: user.id,
phone: user.phone,
nickname: user.nickname,
avatarUrl: user.avatarUrl,
role: user.role as UserRole,
activeMembershipCount,
inviteShareEligible: activeMembershipCount > 0,
adminBookingSubscriptionCount: user.adminBookingSubscriptionCount,
subscriptionMessageTemplates: this.buildSubscriptionTemplateConfig(),
createdAt: user.createdAt.toISOString(),
}
}
async login(
code: string,
nickname?: string,
avatarUrl?: string,
inviterId?: string,
): Promise<LoginResult> {
const { openid, unionid, sessionKey } =
await this.wechatService.code2Session(code)
@@ -69,6 +125,8 @@ export class AuthService {
where: { openid },
})
const isNewUser = existingUser === null
const user =
existingUser ??
(await this.prisma.user.create({
@@ -77,6 +135,7 @@ export class AuthService {
...(unionid !== undefined && { unionid }),
nickname: nickname || generateDefaultNickname(this.randomFn),
...(avatarUrl && { avatarUrl }),
adminBookingSubscriptionCount: 0,
},
}))
@@ -89,15 +148,19 @@ export class AuthService {
sessionKeyStore.set(updated.id, sessionKey)
const payload: JwtPayload = { sub: updated.id, role: updated.role as UserRole }
const token = this.jwtService.sign(payload)
return { token, user: updated }
return { token, user: await this.mapLoginUser(updated), isNewUser: false }
}
sessionKeyStore.set(user.id, sessionKey)
if (isNewUser) {
await this.inviteService.bindInviterToUser(user.id, inviterId)
}
const payload: JwtPayload = { sub: user.id, role: user.role as UserRole }
const token = this.jwtService.sign(payload)
return { token, user }
return { token, user: await this.mapLoginUser(user), isNewUser }
}
async bindPhone(

View File

@@ -12,4 +12,8 @@ export class LoginDto {
@IsString()
@IsOptional()
avatarUrl?: string
@IsString()
@IsOptional()
inviterId?: string
}

View File

@@ -5,11 +5,13 @@ import {
ForbiddenException,
NotFoundException,
} from '@nestjs/common'
import { BookingStatus, CardTypeCategory, MembershipStatus, TimeSlotStatus } from '@mp-pilates/shared'
import { BookingStatus, CardTypeCategory, MembershipStatus, TimeSlotStatus, UserRole } from '@mp-pilates/shared'
import { BookingService } from '../booking.service'
import { PrismaService } from '../../prisma/prisma.service'
import { MembershipService } from '../../membership/membership.service'
import { StudioService } from '../../studio/studio.service'
import { SubscriptionMessageService } from '../../user/subscription-message.service'
import { InviteService } from '../../invite/invite.service'
// ─── Fixtures ──────────────────────────────────────────────────────────────
@@ -138,6 +140,9 @@ function buildTxMock(overrides: Record<string, unknown> = {}) {
findUnique: jest.fn(),
update: jest.fn(),
},
bookingStatusHistory: {
create: jest.fn(),
},
...overrides,
}
}
@@ -148,6 +153,8 @@ describe('BookingService', () => {
let service: BookingService
let prisma: jest.Mocked<PrismaService>
let studioService: jest.Mocked<StudioService>
let subscriptionMessageService: { sendBookingConfirmedMessage: jest.Mock; sendAdminBookingCreatedMessage: jest.Mock }
let inviteService: { recordQualifiedTrialBooking: jest.Mock }
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
@@ -166,12 +173,17 @@ describe('BookingService', () => {
},
timeSlot: {
findUnique: jest.fn(),
findMany: jest.fn(),
update: jest.fn(),
},
membership: {
findUnique: jest.fn(),
update: jest.fn(),
},
user: {
findUnique: jest.fn(),
findMany: jest.fn(),
},
},
},
{
@@ -188,38 +200,141 @@ describe('BookingService', () => {
getInfo: jest.fn(),
},
},
{
provide: SubscriptionMessageService,
useValue: {
sendBookingConfirmedMessage: jest.fn(),
sendAdminBookingCreatedMessage: jest.fn(),
},
},
{
provide: InviteService,
useValue: {
recordQualifiedTrialBooking: jest.fn(),
},
},
],
}).compile()
service = module.get<BookingService>(BookingService)
prisma = module.get(PrismaService) as jest.Mocked<PrismaService>
studioService = module.get(StudioService) as jest.Mocked<StudioService>
subscriptionMessageService = module.get(SubscriptionMessageService)
inviteService = module.get(InviteService)
})
afterEach(() => jest.clearAllMocks())
describe('confirmBooking', () => {
it('sends booking confirmed subscription message after admin confirmation', async () => {
const tx = buildTxMock({
bookingStatusHistory: { create: jest.fn() },
})
tx.booking.findUnique.mockResolvedValue({
...mockConfirmedBooking,
status: BookingStatus.PENDING_CONFIRMATION,
timeSlot: mockOpenSlot,
membership: mockActiveMembership,
})
tx.booking.update.mockResolvedValue({
...mockConfirmedBooking,
status: BookingStatus.CONFIRMED,
confirmedAt: new Date('2099-12-30T00:00:00Z'),
})
tx.timeSlot.update.mockResolvedValue({ ...mockOpenSlot, bookedCount: 1, status: TimeSlotStatus.OPEN })
tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 4 })
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
...mockConfirmedBooking,
status: BookingStatus.CONFIRMED,
confirmedAt: new Date('2099-12-30T00:00:00Z'),
timeSlot: mockOpenSlot,
membership: mockActiveMembership,
})
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ openid: 'openid-001' })
studioService.getInfo.mockResolvedValue({
...mockStudioConfig,
name: 'FocusCore Pilates',
})
subscriptionMessageService.sendBookingConfirmedMessage.mockResolvedValue(true)
await service.confirmBooking(MOCK_BOOKING_ID, 'admin-001')
expect(subscriptionMessageService.sendBookingConfirmedMessage).toHaveBeenCalledWith({
openid: 'openid-001',
bookingId: MOCK_BOOKING_ID,
bookingContent: '预约已确认',
bookingTime: '2099-12-31 09:00',
courseName: 'FocusCore Pilates',
bookingEndTime: '2099-12-31 10:00',
})
})
})
describe('completeBooking', () => {
it('records qualified trial booking after completion', async () => {
const tx = buildTxMock({
bookingStatusHistory: { create: jest.fn() },
})
tx.booking.findUnique.mockResolvedValue({
...mockConfirmedBooking,
status: BookingStatus.CONFIRMED,
timeSlot: mockOpenSlot,
})
tx.booking.update.mockResolvedValue({
...mockConfirmedBooking,
status: BookingStatus.COMPLETED,
completedAt: new Date('2099-12-31T11:00:00Z'),
})
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
...mockConfirmedBooking,
status: BookingStatus.COMPLETED,
completedAt: new Date('2099-12-31T11:00:00Z'),
timeSlot: mockOpenSlot,
membership: {
...mockActiveMembership,
cardType: {
...mockTimesCardType,
type: CardTypeCategory.TRIAL,
},
},
})
await service.completeBooking(MOCK_BOOKING_ID, 'admin-001')
expect(inviteService.recordQualifiedTrialBooking).toHaveBeenCalledWith(MOCK_BOOKING_ID)
})
})
// ─── createBooking ────────────────────────────────────────────────────────
describe('createBooking', () => {
const dto = { timeSlotId: MOCK_SLOT_ID, membershipId: MOCK_MEMBERSHIP_ID }
it('creates booking, increments bookedCount, and deducts membership (TIMES card)', async () => {
it('creates booking in pending confirmation status', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findFirst.mockResolvedValue(null) // no duplicate
tx.booking.findUnique.mockResolvedValue(null) // no duplicate
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
tx.booking.create.mockResolvedValue(mockConfirmedBooking)
tx.timeSlot.update.mockResolvedValue({ ...mockOpenSlot, bookedCount: 1 })
tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 4 })
tx.booking.create.mockResolvedValue({
...mockConfirmedBooking,
status: BookingStatus.PENDING_CONFIRMATION,
})
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
// Mock the re-fetch after transaction
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
...mockConfirmedBooking,
status: BookingStatus.PENDING_CONFIRMATION,
timeSlot: mockOpenSlot,
membership: mockActiveMembership,
})
;(prisma.user.findMany as jest.Mock).mockResolvedValue([])
const result = await service.createBooking(MOCK_USER_ID, dto)
@@ -229,55 +344,46 @@ describe('BookingService', () => {
userId: MOCK_USER_ID,
timeSlotId: MOCK_SLOT_ID,
membershipId: MOCK_MEMBERSHIP_ID,
status: BookingStatus.CONFIRMED,
status: BookingStatus.PENDING_CONFIRMATION,
}),
}),
)
// bookedCount incremented from 0 → 1, still OPEN (capacity 5)
expect(tx.timeSlot.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ bookedCount: 1, status: TimeSlotStatus.OPEN }),
}),
)
// membership deducted from 5 → 4
expect(tx.membership.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
remainingTimes: 4,
status: MembershipStatus.ACTIVE,
}),
}),
)
expect(tx.timeSlot.update).not.toHaveBeenCalled()
expect(tx.membership.update).not.toHaveBeenCalled()
expect(result).toBeDefined()
})
it('sets slot to FULL when bookedCount reaches capacity', async () => {
it('records booking status history when user creates a booking', async () => {
const nearFullSlot = { ...mockOpenSlot, bookedCount: 4, capacity: 5 }
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(nearFullSlot)
tx.booking.findFirst.mockResolvedValue(null)
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
tx.booking.create.mockResolvedValue(mockConfirmedBooking)
tx.timeSlot.update.mockResolvedValue({ ...nearFullSlot, bookedCount: 5, status: TimeSlotStatus.FULL })
tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 4 })
tx.booking.create.mockResolvedValue({
...mockConfirmedBooking,
status: BookingStatus.PENDING_CONFIRMATION,
})
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
...mockConfirmedBooking,
timeSlot: { ...nearFullSlot, status: TimeSlotStatus.FULL },
status: BookingStatus.PENDING_CONFIRMATION,
timeSlot: nearFullSlot,
membership: mockActiveMembership,
})
;(prisma.user.findMany as jest.Mock).mockResolvedValue([])
await service.createBooking(MOCK_USER_ID, dto)
// bookedCount 4+1 = 5 = capacity → FULL
expect(tx.timeSlot.update).toHaveBeenCalledWith(
expect(tx.bookingStatusHistory.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ bookedCount: 5, status: TimeSlotStatus.FULL }),
data: expect.objectContaining({
toStatus: BookingStatus.PENDING_CONFIRMATION,
operatorId: MOCK_USER_ID,
}),
}),
)
})
@@ -287,7 +393,7 @@ describe('BookingService', () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findFirst.mockResolvedValue(null)
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(mockDurationMembership)
tx.booking.create.mockResolvedValue({ ...mockConfirmedBooking, membershipId: mockDurationMembership.id })
tx.timeSlot.update.mockResolvedValue({ ...mockOpenSlot, bookedCount: 1 })
@@ -299,6 +405,7 @@ describe('BookingService', () => {
timeSlot: mockOpenSlot,
membership: mockDurationMembership,
})
;(prisma.user.findMany as jest.Mock).mockResolvedValue([])
await service.createBooking(MOCK_USER_ID, durationDto)
@@ -306,34 +413,78 @@ describe('BookingService', () => {
expect(tx.membership.update).not.toHaveBeenCalled()
})
it('marks membership as USED_UP when remainingTimes hits 0', async () => {
const lastTimeMembership = { ...mockActiveMembership, remainingTimes: 1 }
it('allows time-based membership with zero remaining times and leaves deduction to admin confirmation', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findFirst.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(lastTimeMembership)
tx.booking.create.mockResolvedValue(mockConfirmedBooking)
tx.timeSlot.update.mockResolvedValue({ ...mockOpenSlot, bookedCount: 1 })
tx.membership.update.mockResolvedValue({ ...lastTimeMembership, remainingTimes: 0, status: MembershipStatus.USED_UP })
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(mockMembershipNoTimes)
tx.booking.create.mockResolvedValue({
...mockConfirmedBooking,
membershipId: mockMembershipNoTimes.id,
status: BookingStatus.PENDING_CONFIRMATION,
})
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
...mockConfirmedBooking,
membershipId: mockMembershipNoTimes.id,
status: BookingStatus.PENDING_CONFIRMATION,
timeSlot: mockOpenSlot,
membership: { ...lastTimeMembership, remainingTimes: 0, status: MembershipStatus.USED_UP },
membership: mockMembershipNoTimes,
})
;(prisma.user.findMany as jest.Mock).mockResolvedValue([])
await service.createBooking(MOCK_USER_ID, dto)
expect(tx.membership.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
remainingTimes: 0,
status: MembershipStatus.USED_UP,
}),
}),
)
expect(tx.membership.update).not.toHaveBeenCalled()
})
it('sends admin booking created subscription message to admins with remaining count', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
tx.booking.create.mockResolvedValue({
...mockConfirmedBooking,
status: BookingStatus.PENDING_CONFIRMATION,
})
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
...mockConfirmedBooking,
status: BookingStatus.PENDING_CONFIRMATION,
timeSlot: mockOpenSlot,
membership: mockActiveMembership,
})
;(prisma.user.findMany as jest.Mock).mockResolvedValue([
{ openid: 'admin-openid-1' },
])
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ nickname: 'Alice', phone: '13800000000' })
studioService.getInfo.mockResolvedValue({
...mockStudioConfig,
name: 'FocusCore Pilates',
})
subscriptionMessageService.sendAdminBookingCreatedMessage.mockResolvedValue(true)
await service.createBooking(MOCK_USER_ID, dto)
expect(prisma.user.findMany).toHaveBeenCalledWith({
where: {
role: UserRole.ADMIN,
adminBookingSubscriptionCount: { gt: 0 },
},
select: {
openid: true,
},
})
expect(subscriptionMessageService.sendAdminBookingCreatedMessage).toHaveBeenCalledWith({
openid: 'admin-openid-1',
bookingId: MOCK_BOOKING_ID,
bookingContent: 'Alice已预约',
bookingTime: '2099-12-31 09:00',
courseName: 'FocusCore Pilates',
bookingEndTime: '2099-12-31 10:00',
})
})
it('throws BadRequestException when slot is FULL', async () => {
@@ -352,7 +503,7 @@ describe('BookingService', () => {
it('throws ConflictException on duplicate booking (same user + slot)', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findFirst.mockResolvedValue(mockConfirmedBooking) // duplicate exists
tx.booking.findUnique.mockResolvedValue(mockConfirmedBooking) // duplicate exists
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
@@ -364,7 +515,7 @@ describe('BookingService', () => {
it('throws BadRequestException when membership is not ACTIVE (expired status)', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findFirst.mockResolvedValue(null)
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(mockExpiredMembership)
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
@@ -374,20 +525,6 @@ describe('BookingService', () => {
)
})
it('throws BadRequestException when TIMES membership has 0 remaining', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findFirst.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(mockMembershipNoTimes)
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await expect(service.createBooking(MOCK_USER_ID, dto)).rejects.toThrow(
BadRequestException,
)
expect(tx.booking.create).not.toHaveBeenCalled()
})
it('throws NotFoundException when timeSlot does not exist', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(null)
@@ -404,7 +541,7 @@ describe('BookingService', () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findFirst.mockResolvedValue(null)
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(otherUserMembership)
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
@@ -413,6 +550,65 @@ describe('BookingService', () => {
ForbiddenException,
)
})
it('reuses a cancelled booking record when booking the same slot again', async () => {
const cancelledBooking = {
...mockConfirmedBooking,
status: BookingStatus.CANCELLED,
membershipId: 'mem-old-001',
cancelledAt: new Date('2099-12-30T00:00:00Z'),
confirmedAt: new Date('2099-12-29T00:00:00Z'),
completedAt: null,
operatorId: 'admin-001',
}
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findUnique.mockResolvedValue(cancelledBooking)
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
tx.booking.update.mockResolvedValue({
...cancelledBooking,
membershipId: MOCK_MEMBERSHIP_ID,
status: BookingStatus.PENDING_CONFIRMATION,
cancelledAt: null,
confirmedAt: null,
operatorId: null,
})
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
...mockConfirmedBooking,
status: BookingStatus.PENDING_CONFIRMATION,
timeSlot: mockOpenSlot,
membership: mockActiveMembership,
})
;(prisma.user.findMany as jest.Mock).mockResolvedValue([])
await service.createBooking(MOCK_USER_ID, dto)
expect(tx.booking.create).not.toHaveBeenCalled()
expect(tx.booking.update).toHaveBeenCalledWith({
where: { id: MOCK_BOOKING_ID },
data: {
membershipId: MOCK_MEMBERSHIP_ID,
status: BookingStatus.PENDING_CONFIRMATION,
cancelledAt: null,
confirmedAt: null,
completedAt: null,
operatorId: null,
},
})
expect(tx.bookingStatusHistory.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
bookingId: MOCK_BOOKING_ID,
fromStatus: BookingStatus.CANCELLED,
toStatus: BookingStatus.PENDING_CONFIRMATION,
remark: '学员重新发起预约',
}),
}),
)
})
})
// ─── cancelBooking ────────────────────────────────────────────────────────
@@ -662,7 +858,7 @@ describe('BookingService', () => {
expect.objectContaining({
where: expect.objectContaining({
userId: MOCK_USER_ID,
status: BookingStatus.CONFIRMED,
status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] },
}),
orderBy: [
{ timeSlot: { date: 'asc' } },
@@ -708,4 +904,101 @@ describe('BookingService', () => {
)
})
})
describe('getTeachingScheduleByDate', () => {
it('returns sorted slots with active students only', async () => {
;(prisma.timeSlot.findMany as jest.Mock).mockResolvedValue([
{
id: 'slot-02',
startTime: '11:00',
endTime: '12:00',
bookedCount: 1,
capacity: 1,
bookings: [
{
id: 'booking-02',
status: BookingStatus.CONFIRMED,
createdAt: new Date('2026-04-19T01:00:00Z'),
user: { id: 'user-02', nickname: '李四', phone: '13800000000' },
},
],
},
{
id: 'slot-01',
startTime: '09:00',
endTime: '10:00',
bookedCount: 2,
capacity: 2,
bookings: [
{
id: 'booking-01',
status: BookingStatus.PENDING_CONFIRMATION,
createdAt: new Date('2026-04-19T00:00:00Z'),
user: { id: 'user-01', nickname: '张三', phone: null },
},
],
},
])
const result = await service.getTeachingScheduleByDate('2026-04-19')
expect(prisma.timeSlot.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
bookings: {
some: {
status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] },
},
},
}),
orderBy: [
{ startTime: 'asc' },
{ endTime: 'asc' },
],
}),
)
expect(result).toEqual([
{
slotId: 'slot-01',
date: '2026-04-19',
startTime: '09:00',
endTime: '10:00',
bookedCount: 2,
capacity: 2,
students: [
{
bookingId: 'booking-01',
userId: 'user-01',
nickname: '张三',
phone: null,
status: BookingStatus.PENDING_CONFIRMATION,
},
],
},
{
slotId: 'slot-02',
date: '2026-04-19',
startTime: '11:00',
endTime: '12:00',
bookedCount: 1,
capacity: 1,
students: [
{
bookingId: 'booking-02',
userId: 'user-02',
nickname: '李四',
phone: '13800000000',
status: BookingStatus.CONFIRMED,
},
],
},
])
})
it('rejects invalid date input', async () => {
await expect(service.getTeachingScheduleByDate('invalid-date')).rejects.toThrow(
BadRequestException,
)
})
})
})

View File

@@ -1,4 +1,5 @@
import {
BadRequestException,
Body,
Controller,
Get,
@@ -91,6 +92,16 @@ export class BookingController {
)
}
@Get('admin/teaching-schedule')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
async getTeachingSchedule(@Query('date') date?: string) {
if (!date) {
throw new BadRequestException('date is required')
}
return this.bookingService.getTeachingScheduleByDate(date)
}
@Put('booking/:id/confirm')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)

View File

@@ -3,9 +3,11 @@ import { BookingController } from './booking.controller'
import { BookingService } from './booking.service'
import { MembershipModule } from '../membership/membership.module'
import { StudioModule } from '../studio/studio.module'
import { UserModule } from '../user/user.module'
import { InviteModule } from '../invite/invite.module'
@Module({
imports: [MembershipModule, StudioModule],
imports: [MembershipModule, StudioModule, UserModule, InviteModule],
controllers: [BookingController],
providers: [BookingService],
exports: [BookingService],

View File

@@ -6,11 +6,19 @@ import {
NotFoundException,
} from '@nestjs/common'
import { Booking, Membership, TimeSlot, BookingStatusHistory } from '@prisma/client'
import { BookingStatus, CardTypeCategory, MembershipStatus, TimeSlotStatus } from '@mp-pilates/shared'
import {
BookingStatus,
CardTypeCategory,
MembershipStatus,
TimeSlotStatus,
type TeachingScheduleSlot,
} from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
import { MembershipService } from '../membership/membership.service'
import { StudioService } from '../studio/studio.service'
import { SubscriptionMessageService } from '../user/subscription-message.service'
import { CreateBookingDto } from './dto/create-booking.dto'
import { InviteService } from '../invite/invite.service'
// ─── Types ─────────────────────────────────────────────────────────────────
@@ -48,6 +56,8 @@ export class BookingService {
private readonly prisma: PrismaService,
private readonly membershipService: MembershipService,
private readonly studioService: StudioService,
private readonly subscriptionMessageService: SubscriptionMessageService,
private readonly inviteService: InviteService,
) {}
// ─── Create Booking ──────────────────────────────────────────────────────
@@ -70,15 +80,20 @@ export class BookingService {
)
}
// 2. Check for active (PENDING_CONFIRMATION or CONFIRMED) booking — cancelled bookings don't block re-booking
const existing = await tx.booking.findFirst({
// 2. Find existing booking record for this user + slot.
// The DB keeps a unique key on this pair, so cancelled bookings must be revived instead of recreated.
const existing = await tx.booking.findUnique({
where: {
userId,
timeSlotId: dto.timeSlotId,
status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] },
userId_timeSlotId: {
userId,
timeSlotId: dto.timeSlotId,
},
},
})
if (existing) {
if (
existing &&
(existing.status === 'PENDING_CONFIRMATION' || existing.status === 'CONFIRMED')
) {
throw new ConflictException('You have already booked this time slot')
}
@@ -113,24 +128,40 @@ export class BookingService {
}
}
// 5. Create booking with PENDING_CONFIRMATION status
const newBooking = await tx.booking.create({
data: {
userId,
timeSlotId: dto.timeSlotId,
membershipId: dto.membershipId,
status: BookingStatus.PENDING_CONFIRMATION,
},
})
// 5. Create booking or revive a previously cancelled booking.
const newBooking = existing?.status === BookingStatus.CANCELLED
? await tx.booking.update({
where: { id: existing.id },
data: {
membershipId: dto.membershipId,
status: BookingStatus.PENDING_CONFIRMATION,
cancelledAt: null,
confirmedAt: null,
completedAt: null,
operatorId: null,
},
})
: await tx.booking.create({
data: {
userId,
timeSlotId: dto.timeSlotId,
membershipId: dto.membershipId,
status: BookingStatus.PENDING_CONFIRMATION,
},
})
// 6. Record status history: created
// 6. Record status history: created or re-created from cancelled state.
await tx.bookingStatusHistory.create({
data: {
bookingId: newBooking.id,
fromStatus: null,
fromStatus: existing?.status === BookingStatus.CANCELLED
? BookingStatus.CANCELLED
: null,
toStatus: BookingStatus.PENDING_CONFIRMATION,
operatorId: userId,
remark: '学员发起预约',
remark: existing?.status === BookingStatus.CANCELLED
? '学员重新发起预约'
: '学员发起预约',
},
})
@@ -138,7 +169,9 @@ export class BookingService {
})
// Re-fetch with relations after transaction
return this.fetchBookingWithRelations(booking.id)
const bookingWithRelations = await this.fetchBookingWithRelations(booking.id)
await this.trySendAdminBookingCreatedSubscriptionMessages(bookingWithRelations)
return bookingWithRelations
}
// ─── Confirm Booking (Admin) ─────────────────────────────────────────────
@@ -235,7 +268,9 @@ export class BookingService {
return updated
})
return this.fetchBookingWithRelations(booking.id)
const confirmedBooking = await this.fetchBookingWithRelations(booking.id)
await this.trySendBookingConfirmedSubscriptionMessage(confirmedBooking)
return confirmedBooking
}
// ─── Complete / NoShow Booking (Admin) ──────────────────────────────────
@@ -303,7 +338,11 @@ export class BookingService {
return updated
})
return this.fetchBookingWithRelations(booking.id)
const result = await this.fetchBookingWithRelations(booking.id)
if (toStatus === BookingStatus.COMPLETED) {
await this.inviteService.recordQualifiedTrialBooking(result.id)
}
return result
}
// ─── Cancel Booking ──────────────────────────────────────────────────────
@@ -549,6 +588,72 @@ export class BookingService {
}
}
async getTeachingScheduleByDate(date: string): Promise<TeachingScheduleSlot[]> {
const dayStart = new Date(`${date}T00:00:00.000Z`)
if (Number.isNaN(dayStart.getTime())) {
throw new BadRequestException('Invalid date')
}
const slots = await this.prisma.timeSlot.findMany({
where: {
date: dayStart,
bookings: {
some: {
status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] },
},
},
},
include: {
bookings: {
where: {
status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] },
},
include: {
user: {
select: {
id: true,
nickname: true,
phone: true,
},
},
},
orderBy: [
{ status: 'asc' },
{ createdAt: 'asc' },
],
},
},
orderBy: [
{ startTime: 'asc' },
{ endTime: 'asc' },
],
})
return slots
.map((slot) => ({
slotId: slot.id,
date,
startTime: slot.startTime,
endTime: slot.endTime,
bookedCount: slot.bookedCount,
capacity: slot.capacity,
students: slot.bookings.map((booking) => ({
bookingId: booking.id,
userId: booking.user.id,
nickname: booking.user.nickname,
phone: booking.user.phone,
status: booking.status as BookingStatus,
})),
}))
.sort((a, b) => {
const byStart = a.startTime.localeCompare(b.startTime)
if (byStart !== 0) {
return byStart
}
return a.endTime.localeCompare(b.endTime)
})
}
// ─── Private Helpers ─────────────────────────────────────────────────────
private async fetchBookingWithRelations(bookingId: string): Promise<BookingWithRelations> {
@@ -566,4 +671,90 @@ export class BookingService {
return { ...booking } as BookingWithRelations
}
private async trySendBookingConfirmedSubscriptionMessage(
booking: BookingWithRelations,
): Promise<void> {
try {
const user = await this.prisma.user.findUnique({
where: { id: booking.userId },
select: { openid: true },
})
if (!user?.openid) {
return
}
const studio = await this.studioService.getInfo()
const bookingDate = booking.timeSlot.date
const dateLabel = `${bookingDate.getFullYear()}-${String(bookingDate.getMonth() + 1).padStart(2, '0')}-${String(bookingDate.getDate()).padStart(2, '0')}`
await this.subscriptionMessageService.sendBookingConfirmedMessage({
openid: user.openid,
bookingId: booking.id,
bookingContent: '预约已确认',
bookingTime: `${dateLabel} ${booking.timeSlot.startTime.slice(0, 5)}`,
courseName: studio.name || '普拉提课程',
bookingEndTime: `${dateLabel} ${booking.timeSlot.endTime.slice(0, 5)}`,
})
} catch (error) {
console.error('Send booking confirmed subscription message failed:', error)
}
}
private async trySendAdminBookingCreatedSubscriptionMessages(
booking: BookingWithRelations,
): Promise<void> {
try {
const admins = await this.prisma.user.findMany({
where: {
role: 'ADMIN',
adminBookingSubscriptionCount: { gt: 0 },
},
select: {
openid: true,
},
})
if (admins.length === 0) {
return
}
const student = await this.prisma.user.findUnique({
where: { id: booking.userId },
select: { nickname: true, phone: true },
})
const studio = await this.studioService.getInfo()
const bookingDate = booking.timeSlot.date
const dateLabel = `${bookingDate.getFullYear()}-${String(bookingDate.getMonth() + 1).padStart(2, '0')}-${String(bookingDate.getDate()).padStart(2, '0')}`
const studentLabel = this.buildAdminBookingStudentLabel(student)
await Promise.allSettled(
admins
.filter((admin) => admin.openid)
.map((admin) => this.subscriptionMessageService.sendAdminBookingCreatedMessage({
openid: admin.openid,
bookingId: booking.id,
bookingContent: `${studentLabel}已预约`.slice(0, 20),
bookingTime: `${dateLabel} ${booking.timeSlot.startTime.slice(0, 5)}`,
courseName: studio.name || '普拉提课程',
bookingEndTime: `${dateLabel} ${booking.timeSlot.endTime.slice(0, 5)}`,
})),
)
} catch (error) {
console.error('Send admin booking created subscription message failed:', error)
}
}
private buildAdminBookingStudentLabel(student: { nickname: string; phone: string | null } | null): string {
const nickname = (student?.nickname || '').trim()
if (nickname) {
return nickname.slice(0, 8)
}
const phone = student?.phone || ''
if (phone.length >= 4) {
return `尾号${phone.slice(-4)}`
}
return '学员'
}
}

View File

@@ -8,6 +8,7 @@ import {
} from '@nestjs/common'
import type { Request, Response } from 'express'
import type { ApiResponse } from '@mp-pilates/shared'
import { formatRequestExtras } from '../utils/request-log'
@Catch()
export class ApiExceptionFilter implements ExceptionFilter {
@@ -28,15 +29,16 @@ export class ApiExceptionFilter implements ExceptionFilter {
? this.extractMessage(exception)
: '服务器内部错误'
// Log all server errors (5xx) with full stack; log 4xx at warn level
const extras = formatRequestExtras(request)
if (status >= 500) {
this.logger.error(
`${request.method} ${request.originalUrl}${String(status)} ${message}`,
`${request.method} ${request.originalUrl}${String(status)} ${message}${extras}`,
exception instanceof Error ? exception.stack : undefined,
)
} else if (status >= 400) {
this.logger.warn(
`${request.method} ${request.originalUrl}${String(status)} ${message}`,
`${request.method} ${request.originalUrl}${String(status)} ${message}${extras}`,
)
}

View File

@@ -7,28 +7,7 @@ import {
} from '@nestjs/common'
import { Observable, tap } from 'rxjs'
import type { Request, Response } from 'express'
/** Fields stripped from logged request bodies to avoid leaking secrets. */
const SENSITIVE_FIELDS: ReadonlySet<string> = new Set([
'password',
'token',
'secret',
'code',
'sessionKey',
'encryptedData',
'iv',
])
function sanitizeBody(
body: Record<string, unknown> | undefined,
): Record<string, unknown> | undefined {
if (!body || typeof body !== 'object') return undefined
return Object.fromEntries(
Object.entries(body).map(([key, value]) =>
SENSITIVE_FIELDS.has(key) ? [key, '***'] : [key, value],
),
)
}
import { formatRequestExtras } from '../utils/request-log'
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
@@ -44,9 +23,9 @@ export class LoggingInterceptor implements NestInterceptor {
next: () => {
const res = context.switchToHttp().getResponse<Response>()
const duration = Date.now() - start
const bodyLog = this.formatBody(method, req.body as Record<string, unknown>)
const extras = formatRequestExtras(req)
this.logger.log(
`${method} ${originalUrl}${String(res.statusCode)} (${String(duration)}ms)${bodyLog}`,
`${method} ${originalUrl}${String(res.statusCode)} (${String(duration)}ms)${extras}`,
)
},
error: (err: unknown) => {
@@ -55,22 +34,12 @@ export class LoggingInterceptor implements NestInterceptor {
err instanceof Object && 'getStatus' in err
? String((err as { getStatus: () => number }).getStatus())
: '500'
const bodyLog = this.formatBody(method, req.body as Record<string, unknown>)
const extras = formatRequestExtras(req)
this.logger.error(
`${method} ${originalUrl}${status} (${String(duration)}ms)${bodyLog}`,
`${method} ${originalUrl}${status} (${String(duration)}ms)${extras}`,
)
},
}),
)
}
private formatBody(
method: string,
body: Record<string, unknown> | undefined,
): string {
if (!['POST', 'PUT', 'PATCH'].includes(method)) return ''
const sanitized = sanitizeBody(body)
if (!sanitized || Object.keys(sanitized).length === 0) return ''
return ` body=${JSON.stringify(sanitized)}`
}
}

View File

@@ -0,0 +1,60 @@
import type { Request } from 'express'
/** Fields stripped from logged request bodies to avoid leaking secrets. */
const SENSITIVE_FIELDS: ReadonlySet<string> = new Set([
'password',
'token',
'secret',
'code',
'sessionKey',
'encryptedData',
'iv',
])
const BODY_METHODS: ReadonlySet<string> = new Set(['POST', 'PUT', 'PATCH'])
/** Max characters of JSON-serialised body/query included in a log line. */
const MAX_LOG_PAYLOAD = 2048
function truncate(value: string): string {
return value.length > MAX_LOG_PAYLOAD
? `${value.slice(0, MAX_LOG_PAYLOAD)}…(truncated)`
: value
}
export function sanitizeBody(
body: Record<string, unknown> | undefined,
): Record<string, unknown> | undefined {
if (!body || typeof body !== 'object') return undefined
const keys = Object.keys(body)
if (keys.length === 0) return undefined
const result: Record<string, unknown> = {}
for (const key of keys) {
result[key] = SENSITIVE_FIELDS.has(key) ? '***' : body[key]
}
return result
}
/**
* Build a human-readable suffix for a log line:
* ` | query={…} body={…}`
* Returns an empty string when there is nothing to append.
*/
export function formatRequestExtras(request: Request): string {
const parts: string[] = []
const query = request.query
if (query && Object.keys(query).length > 0) {
parts.push(`query=${truncate(JSON.stringify(query))}`)
}
if (BODY_METHODS.has(request.method)) {
const sanitized = sanitizeBody(request.body as Record<string, unknown>)
if (sanitized) {
parts.push(`body=${truncate(JSON.stringify(sanitized))}`)
}
}
return parts.length > 0 ? ` | ${parts.join(' ')}` : ''
}

View File

@@ -0,0 +1,35 @@
import { IsUUID, IsString, IsInt, IsDateString, IsOptional, Min, IsNumber } from 'class-validator'
export class CreateFlashSaleDto {
@IsUUID()
cardTypeId!: string
@IsString()
title!: string
@IsNumber()
@Min(1)
originalPrice!: number
@IsNumber()
@Min(1)
flashPrice!: number
@IsInt()
@Min(1)
totalStock!: number
@IsDateString()
startTime!: string
@IsDateString()
endTime!: string
@IsOptional()
@IsString()
description?: string
@IsOptional()
@IsInt()
sortOrder?: number
}

View File

@@ -0,0 +1,43 @@
import { IsString, IsInt, IsDateString, IsOptional, Min, IsNumber, IsEnum } from 'class-validator'
import { FlashSaleStatus } from '@mp-pilates/shared'
export class UpdateFlashSaleDto {
@IsOptional()
@IsString()
title?: string
@IsOptional()
@IsNumber()
@Min(1)
originalPrice?: number
@IsOptional()
@IsNumber()
@Min(1)
flashPrice?: number
@IsOptional()
@IsInt()
@Min(1)
totalStock?: number
@IsOptional()
@IsDateString()
startTime?: string
@IsOptional()
@IsDateString()
endTime?: string
@IsOptional()
@IsString()
description?: string
@IsOptional()
@IsEnum(FlashSaleStatus)
status?: FlashSaleStatus
@IsOptional()
@IsInt()
sortOrder?: number
}

View File

@@ -0,0 +1,67 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Put,
Query,
UseGuards,
ValidationPipe,
} from '@nestjs/common'
import { UserRole } from '@mp-pilates/shared'
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
import { RolesGuard } from '../auth/roles.guard'
import { Roles } from '../auth/roles.decorator'
import { FlashSaleService } from './flash-sale.service'
import { CreateFlashSaleDto } from './dto/create-flash-sale.dto'
import { UpdateFlashSaleDto } from './dto/update-flash-sale.dto'
@Controller('admin/flash-sales')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
export class FlashSaleAdminController {
constructor(private readonly flashSaleService: FlashSaleService) {}
/** POST /admin/flash-sales — create */
@Post()
create(
@Body(new ValidationPipe({ whitelist: true })) dto: CreateFlashSaleDto,
) {
return this.flashSaleService.createFlashSale(dto)
}
/** GET /admin/flash-sales — list (paginated) */
@Get()
list(
@Query('page') page?: string,
@Query('limit') limit?: string,
) {
return this.flashSaleService.getAdminFlashSales(
page ? parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 20,
)
}
/** GET /admin/flash-sales/:id — detail */
@Get(':id')
detail(@Param('id') id: string) {
return this.flashSaleService.getFlashSaleDetail(id)
}
/** PUT /admin/flash-sales/:id — update */
@Put(':id')
update(
@Param('id') id: string,
@Body(new ValidationPipe({ whitelist: true })) dto: UpdateFlashSaleDto,
) {
return this.flashSaleService.updateFlashSale(id, dto)
}
/** DELETE /admin/flash-sales/:id — delete (DRAFT only) */
@Delete(':id')
remove(@Param('id') id: string) {
return this.flashSaleService.deleteFlashSale(id)
}
}

View File

@@ -0,0 +1,40 @@
import {
Controller,
Get,
Param,
Post,
UseGuards,
} from '@nestjs/common'
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
import { CurrentUser } from '../common/decorators/current-user.decorator'
import { FlashSaleService } from './flash-sale.service'
@Controller('flash-sales')
export class FlashSaleController {
constructor(private readonly flashSaleService: FlashSaleService) {}
/** GET /flash-sales — list active/upcoming (public) */
@Get()
getActiveFlashSales() {
return this.flashSaleService.getActiveFlashSales()
}
/** GET /flash-sales/:id — detail (optionally authenticated) */
@Get(':id')
getFlashSaleDetail(
@Param('id') id: string,
@CurrentUser('sub') userId?: string,
) {
return this.flashSaleService.getFlashSaleDetail(id, userId)
}
/** POST /flash-sales/:id/purchase — requires auth */
@Post(':id/purchase')
@UseGuards(JwtAuthGuard)
purchase(
@Param('id') flashSaleId: string,
@CurrentUser('sub') userId: string,
) {
return this.flashSaleService.purchase(flashSaleId, userId)
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common'
import { PrismaModule } from '../prisma/prisma.module'
import { PaymentModule } from '../payment/payment.module'
import { FlashSaleService } from './flash-sale.service'
import { FlashSaleController } from './flash-sale.controller'
import { FlashSaleAdminController } from './flash-sale-admin.controller'
@Module({
imports: [PrismaModule, PaymentModule],
controllers: [FlashSaleController, FlashSaleAdminController],
providers: [FlashSaleService],
exports: [FlashSaleService],
})
export class FlashSaleModule {}

View File

@@ -0,0 +1,409 @@
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common'
import { Prisma } from '@prisma/client'
import {
FlashSaleStatus,
FlashSaleOrderStatus,
MembershipStatus,
OrderStatus,
} from '@mp-pilates/shared'
import { FlashSalePhase } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
import { WechatPayService, WxPaymentParams } from '../payment/wechat-pay.service'
import { CreateFlashSaleDto } from './dto/create-flash-sale.dto'
import { UpdateFlashSaleDto } from './dto/update-flash-sale.dto'
// ── Helpers ─────────────────────────────────────────────────
function computePhase(sale: {
startTime: Date
endTime: Date
soldCount: number
totalStock: number
status: string
}): FlashSalePhase {
if (sale.status === FlashSaleStatus.ENDED) return FlashSalePhase.ENDED
const now = new Date()
if (now < sale.startTime) return FlashSalePhase.UPCOMING
if (now > sale.endTime) return FlashSalePhase.ENDED
if (sale.soldCount >= sale.totalStock) return FlashSalePhase.SOLD_OUT
return FlashSalePhase.ONGOING
}
function toNumber(val: Prisma.Decimal | number): number {
return typeof val === 'number' ? val : Number(val)
}
// ── Service ─────────────────────────────────────────────────
@Injectable()
export class FlashSaleService {
private readonly logger = new Logger(FlashSaleService.name)
constructor(
private readonly prisma: PrismaService,
private readonly wechatPayService: WechatPayService,
) {}
// ═══════════════════════════════════════════════════════════
// USER: List active/upcoming flash sales
// ═══════════════════════════════════════════════════════════
async getActiveFlashSales() {
const sales = await this.prisma.flashSale.findMany({
where: {
status: FlashSaleStatus.ACTIVE,
endTime: { gt: new Date() },
},
include: {
cardType: {
select: { name: true, type: true, totalTimes: true, durationDays: true },
},
},
orderBy: [{ sortOrder: 'asc' }, { startTime: 'asc' }],
})
return sales.map((s) => ({
...s,
originalPrice: toNumber(s.originalPrice),
flashPrice: toNumber(s.flashPrice),
phase: computePhase(s),
remainingStock: s.totalStock - s.soldCount,
cardType: s.cardType,
}))
}
// ═══════════════════════════════════════════════════════════
// USER: Get detail (with participation check)
// ═══════════════════════════════════════════════════════════
async getFlashSaleDetail(id: string, userId?: string) {
const sale = await this.prisma.flashSale.findUnique({
where: { id },
include: {
cardType: {
select: {
name: true,
type: true,
totalTimes: true,
durationDays: true,
description: true,
},
},
},
})
if (!sale) throw new NotFoundException('秒杀活动不存在')
let hasParticipated = false
let userOrderStatus: FlashSaleOrderStatus | null = null
if (userId) {
const existing = await this.prisma.flashSaleOrder.findUnique({
where: { flashSaleId_userId: { flashSaleId: id, userId } },
})
if (existing && existing.status !== FlashSaleOrderStatus.EXPIRED) {
hasParticipated = true
userOrderStatus = existing.status as FlashSaleOrderStatus
}
}
return {
...sale,
originalPrice: toNumber(sale.originalPrice),
flashPrice: toNumber(sale.flashPrice),
phase: computePhase(sale),
remainingStock: sale.totalStock - sale.soldCount,
cardType: { ...sale.cardType },
hasParticipated,
userOrderStatus,
serverTime: new Date().toISOString(),
}
}
// ═══════════════════════════════════════════════════════════
// PURCHASE — Atomic stock deduction
// ═══════════════════════════════════════════════════════════
async purchase(flashSaleId: string, userId: string) {
// ① Pre-validate (fast-fail before transaction)
const user = await this.prisma.user.findUnique({ where: { id: userId } })
if (!user) throw new NotFoundException('用户不存在')
if (!user.phone) throw new BadRequestException('请先授权手机号后再参与秒杀')
const sale = await this.prisma.flashSale.findUnique({
where: { id: flashSaleId },
include: { cardType: true },
})
if (!sale) throw new NotFoundException('秒杀活动不存在')
if (sale.status !== FlashSaleStatus.ACTIVE) {
throw new BadRequestException('秒杀活动未上线')
}
const now = new Date()
if (now < sale.startTime) throw new BadRequestException('秒杀尚未开始')
if (now > sale.endTime) throw new BadRequestException('秒杀已结束')
// ② Atomic transaction: reserve stock + create FlashSaleOrder + create Order
let result: { order: { id: string; orderNo: string; amount: Prisma.Decimal }; flashSaleOrderId: string }
try {
result = await this.prisma.$transaction(async (tx) => {
// ②-a: CAS optimistic lock stock deduction
const updated = await tx.flashSale.updateMany({
where: {
id: flashSaleId,
soldCount: { lt: sale.totalStock },
},
data: {
soldCount: { increment: 1 },
},
})
if (updated.count === 0) {
throw new BadRequestException('手慢了,已售罄')
}
// ②-b: Create Order with flash sale price
const orderNo = `FS${Date.now()}${Math.random().toString(36).substring(2, 8)}`
const order = await tx.order.create({
data: {
userId,
cardTypeId: sale.cardTypeId,
orderNo,
amount: sale.flashPrice,
status: OrderStatus.PENDING,
flashSaleId,
},
})
// ②-c: Create FlashSaleOrder (unique constraint prevents duplicate)
const flashSaleOrder = await tx.flashSaleOrder.create({
data: {
flashSaleId,
userId,
orderId: order.id,
status: FlashSaleOrderStatus.RESERVED,
},
})
return {
order: { id: order.id, orderNo: order.orderNo, amount: order.amount },
flashSaleOrderId: flashSaleOrder.id,
}
})
} catch (err) {
// Handle unique constraint violation (user already participated)
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
throw new ConflictException('您已参与过此秒杀活动')
}
throw err
}
// ③ Create WeChat unified order (outside transaction — network call)
const paymentParams = await this.wechatPayService.createUnifiedOrder({
orderNo: result.order.orderNo,
amount: toNumber(result.order.amount),
openid: user.openid,
description: `秒杀-${sale.title}`,
})
return {
flashSaleOrderId: result.flashSaleOrderId,
order: {
id: result.order.id,
orderNo: result.order.orderNo,
amount: toNumber(result.order.amount),
},
paymentParams,
}
}
// ═══════════════════════════════════════════════════════════
// ADMIN: Create flash sale
// ═══════════════════════════════════════════════════════════
async createFlashSale(dto: CreateFlashSaleDto) {
const cardType = await this.prisma.cardType.findUnique({
where: { id: dto.cardTypeId },
})
if (!cardType) throw new NotFoundException('卡种不存在')
const startTime = new Date(dto.startTime)
const endTime = new Date(dto.endTime)
if (endTime <= startTime) {
throw new BadRequestException('结束时间必须晚于开始时间')
}
const sale = await this.prisma.flashSale.create({
data: {
cardTypeId: dto.cardTypeId,
title: dto.title,
originalPrice: dto.originalPrice,
flashPrice: dto.flashPrice,
totalStock: dto.totalStock,
startTime,
endTime,
description: dto.description ?? null,
sortOrder: dto.sortOrder ?? 0,
status: FlashSaleStatus.DRAFT,
},
include: {
cardType: { select: { name: true, type: true } },
},
})
return {
...sale,
originalPrice: toNumber(sale.originalPrice),
flashPrice: toNumber(sale.flashPrice),
phase: computePhase(sale),
cardType: { ...sale.cardType },
}
}
// ═══════════════════════════════════════════════════════════
// ADMIN: Update flash sale
// ═══════════════════════════════════════════════════════════
async updateFlashSale(id: string, dto: UpdateFlashSaleDto) {
const existing = await this.prisma.flashSale.findUnique({ where: { id } })
if (!existing) throw new NotFoundException('秒杀活动不存在')
const data: Record<string, unknown> = {}
if (dto.title !== undefined) data.title = dto.title
if (dto.originalPrice !== undefined) data.originalPrice = dto.originalPrice
if (dto.flashPrice !== undefined) data.flashPrice = dto.flashPrice
if (dto.totalStock !== undefined) {
if (dto.totalStock < existing.soldCount) {
throw new BadRequestException('库存不能小于已售数量')
}
data.totalStock = dto.totalStock
}
if (dto.startTime !== undefined) data.startTime = new Date(dto.startTime)
if (dto.endTime !== undefined) data.endTime = new Date(dto.endTime)
if (dto.description !== undefined) data.description = dto.description
if (dto.status !== undefined) data.status = dto.status
if (dto.sortOrder !== undefined) data.sortOrder = dto.sortOrder
const sale = await this.prisma.flashSale.update({
where: { id },
data,
include: {
cardType: { select: { name: true, type: true } },
},
})
return {
...sale,
originalPrice: toNumber(sale.originalPrice),
flashPrice: toNumber(sale.flashPrice),
phase: computePhase(sale),
cardType: { ...sale.cardType },
}
}
// ═══════════════════════════════════════════════════════════
// ADMIN: Delete flash sale (only DRAFT)
// ═══════════════════════════════════════════════════════════
async deleteFlashSale(id: string) {
const existing = await this.prisma.flashSale.findUnique({ where: { id } })
if (!existing) throw new NotFoundException('秒杀活动不存在')
if (existing.soldCount > 0) {
throw new BadRequestException('已有用户参与,无法删除,请结束活动')
}
await this.prisma.flashSale.delete({ where: { id } })
return { deleted: true }
}
// ═══════════════════════════════════════════════════════════
// ADMIN: List all flash sales (paginated)
// ═══════════════════════════════════════════════════════════
async getAdminFlashSales(page = 1, limit = 20) {
const skip = (page - 1) * limit
const [data, total] = await Promise.all([
this.prisma.flashSale.findMany({
include: {
cardType: { select: { name: true, type: true } },
},
orderBy: { createdAt: 'desc' },
skip,
take: limit,
}),
this.prisma.flashSale.count(),
])
return {
data: data.map((s) => ({
...s,
originalPrice: toNumber(s.originalPrice),
flashPrice: toNumber(s.flashPrice),
phase: computePhase(s),
cardType: s.cardType,
})),
total,
page,
limit,
}
}
// ═══════════════════════════════════════════════════════════
// SCHEDULER: Expire unpaid reservations (release stock)
// ═══════════════════════════════════════════════════════════
async expireUnpaidReservations(expireMinutes = 15): Promise<number> {
const cutoff = new Date(Date.now() - expireMinutes * 60_000)
const expiredOrders = await this.prisma.flashSaleOrder.findMany({
where: {
status: FlashSaleOrderStatus.RESERVED,
reservedAt: { lt: cutoff },
},
})
if (expiredOrders.length === 0) return 0
// Group by flashSaleId to batch stock release
const stockDecrements = new Map<string, number>()
const orderIds: string[] = []
const flashSaleOrderIds: string[] = []
for (const fo of expiredOrders) {
flashSaleOrderIds.push(fo.id)
stockDecrements.set(fo.flashSaleId, (stockDecrements.get(fo.flashSaleId) ?? 0) + 1)
if (fo.orderId) orderIds.push(fo.orderId)
}
try {
await this.prisma.$transaction([
// Batch mark all as expired
this.prisma.flashSaleOrder.updateMany({
where: { id: { in: flashSaleOrderIds } },
data: { status: FlashSaleOrderStatus.EXPIRED, expiredAt: new Date() },
}),
// Release stock per flash sale
...Array.from(stockDecrements.entries()).map(([flashSaleId, count]) =>
this.prisma.flashSale.update({
where: { id: flashSaleId },
data: { soldCount: { decrement: count } },
}),
),
// Cancel associated payment orders
...(orderIds.length > 0
? [
this.prisma.order.updateMany({
where: { id: { in: orderIds } },
data: { status: OrderStatus.REFUNDED },
}),
]
: []),
])
} catch (err) {
this.logger.error('Failed to batch-expire flash sale orders', err)
return 0
}
return expiredOrders.length
}
}

View File

@@ -0,0 +1,3 @@
export const INVITE_REWARD_REQUIRED_COUNT = 3
export const INVITE_REWARD_TIMES = 1

View File

@@ -0,0 +1,16 @@
import { Controller, Get, UseGuards } from '@nestjs/common'
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
import { CurrentUser } from '../common/decorators/current-user.decorator'
import { InviteService } from './invite.service'
@Controller('invite')
@UseGuards(JwtAuthGuard)
export class InviteController {
constructor(private readonly inviteService: InviteService) {}
@Get('activity')
getActivity(@CurrentUser('sub') userId: string) {
return this.inviteService.getInviteActivitySummary(userId)
}
}

Some files were not shown because too many files have changed in this diff Show More