Compare commits
39 Commits
3a9982209f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14d7c03b05 | ||
|
|
bd3d519b4f | ||
|
|
9575210b06 | ||
|
|
b02f38dcc7 | ||
|
|
4dacd908a6 | ||
|
|
6ab16f508a | ||
|
|
7ce7cef77c | ||
|
|
52cc3a2985 | ||
|
|
497837c1d8 | ||
|
|
d45a5b2c14 | ||
|
|
f78cdcc9d1 | ||
|
|
1f45c3dc3f | ||
|
|
6cee28bf66 | ||
|
|
c60821c5ff | ||
|
|
9639f44698 | ||
|
|
0810f71250 | ||
|
|
54e30da003 | ||
|
|
57e3227af0 | ||
|
|
54104c16d2 | ||
|
|
a40b4e47e5 | ||
|
|
74551085e3 | ||
|
|
23bdd05811 | ||
|
|
91abedcb86 | ||
|
|
0a20aef678 | ||
|
|
e6056bcab1 | ||
|
|
f14da2c538 | ||
|
|
b281990808 | ||
|
|
cea78aa8d0 | ||
|
|
0776bd8630 | ||
|
|
867461f892 | ||
|
|
13b75c3bed | ||
|
|
f7f18f5178 | ||
|
|
0ca93ec97e | ||
|
|
58c7588a96 | ||
|
|
f94b48203f | ||
|
|
168968f073 | ||
|
|
ba4e4bb301 | ||
|
|
f8268cb6f6 | ||
|
|
66d47ec162 |
252
.claude/skills/wechat-devtools-http-preview/SKILL.md
Normal file
252
.claude/skills/wechat-devtools-http-preview/SKILL.md
Normal 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`
|
||||||
|
|
||||||
@@ -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
37
AGENTS.md
Normal 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.
|
||||||
@@ -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
|
|
||||||
```
|
|
||||||
|
|
||||||
@@ -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
|
|
||||||
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
|
|
||||||
@@ -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
|
|
||||||
@@ -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 ║
|
|
||||||
║ } ║
|
|
||||||
║ } ║
|
|
||||||
║ ║
|
|
||||||
╚══════════════════════════════════════════════════════════════════════════════╝
|
|
||||||
@@ -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
|
|
||||||
|
|
||||||
@@ -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
198
CLAUDE.md
@@ -1,150 +1,88 @@
|
|||||||
# CLAUDE.md
|
# 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 + uni-app(微信小程序前端)
|
||||||
- **packages/app** — Vue 3 + Pinia 前端,基于 Uni-app(目标平台 mp-weixin)
|
- **packages/server** - NestJS(后端 API 服务)
|
||||||
- **packages/shared** — 前后端共用的 TypeScript 类型、枚举和常量
|
- **packages/shared** - TypeScript 类型定义、枚举、常量(前后端共用)
|
||||||
|
|
||||||
## 常用命令
|
## 常用命令
|
||||||
|
|
||||||
### 开发
|
|
||||||
```bash
|
```bash
|
||||||
pnpm dev:server # NestJS watch 模式 (localhost:3000)
|
# 开发
|
||||||
pnpm dev:app # 微信小程序开发服务器
|
pnpm dev:server # 启动 NestJS 后端(热重载)
|
||||||
pnpm build:shared # 必须先构建 shared,再构建 server/app
|
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
|
cd packages/server
|
||||||
pnpm test # 运行全部测试
|
pnpm prisma:generate # 生成 Prisma 客户端
|
||||||
pnpm test -- auth.service.spec # 运行单个测试文件
|
pnpm prisma:migrate # 执行数据库迁移
|
||||||
pnpm test:watch # watch 模式
|
pnpm prisma:seed # 填充测试数据
|
||||||
pnpm test:cov # 覆盖率报告
|
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
|
- Prisma schema 位于 `packages/server/prisma/schema.prisma`
|
||||||
cd packages/server
|
- 核心数据模型:User、Studio、TimeSlot、Booking、Membership、CardType、Order
|
||||||
pnpm prisma:generate # schema 变更后重新生成 Prisma Client
|
- 注意:查询会员列表时,booking 统计通过 `groupBy` 批量获取,避免 N+1 查询
|
||||||
pnpm prisma:migrate # 运行迁移(交互式)
|
|
||||||
pnpm prisma:seed # 填充种子数据
|
|
||||||
```
|
|
||||||
|
|
||||||
### 代码检查
|
### 卡类型枚举
|
||||||
```bash
|
- `CardTypeCategory` (TIMES/DURATION/TRIAL) 定义在 `packages/shared/src/enums.ts`
|
||||||
pnpm lint # 所有包的 ESLint 检查
|
- 会员管理筛选使用特殊值 `NONE` 表示无卡/无有效会员(不在枚举中)
|
||||||
```
|
- 前端选项硬编码在 `src/pages/admin/members.vue` 的 `cardTypeOptions`,需与枚举保持同步
|
||||||
|
|
||||||
## 架构
|
### 管理后台 API 模式
|
||||||
|
- `/admin/members` 支持 `page`, `limit`, `search`, `cardType` 参数
|
||||||
|
- `cardType=NONE` → 无有效会员的用户;其他值对应 `CardTypeCategory`
|
||||||
|
- 预约统计(total/completed/cancelled)通过 `groupBy` 批量查询
|
||||||
|
|
||||||
### 数据流
|
### 筛选组件模式
|
||||||
```
|
- picker 筛选使用 300ms debounce 再触发加载,避免频繁请求
|
||||||
微信小程序 → Uni-app (Vue 3) → REST API (NestJS) → Prisma → PostgreSQL
|
- 列表分页使用 `onReachBottom` + `hasMore` 标志位实现无限滚动
|
||||||
↕ ↕
|
|
||||||
Pinia stores @nestjs/schedule (定时任务)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 后端模块结构
|
### Admin Store (`src/stores/admin.ts`)
|
||||||
每个功能是一个 NestJS 模块,遵循 controller → service → Prisma 模式。核心模块:
|
- 聚合所有管理端 API 调用:weekTemplates、cardTypes、studioConfig、members、bookings、orders、stats 等
|
||||||
- **auth** — 微信 OAuth 登录(code2Session)、JWT 令牌、手机号绑定
|
- 遵循不可变更新原则:`data` 赋值使用展开运算符 `[...newData]`
|
||||||
- **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>` 块内直接写十六进制颜色值(背景色、文字色、边框、阴影均需走变量)
|
|
||||||
@@ -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
|
|
||||||
```
|
|
||||||
|
|
||||||
@@ -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/`
|
|
||||||
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
|
|
||||||
@@ -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
|
|
||||||
|
|
||||||
@@ -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
|
|
||||||
```
|
|
||||||
|
|
||||||
@@ -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
338
docs/STUDIO_COS_SETUP.md
Normal 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`。
|
||||||
|
|
||||||
|
这样后面扩展、迁移、审计都会更稳。
|
||||||
946
docs/superpowers/plans/2026-04-07-member-card-edit.md
Normal file
946
docs/superpowers/plans/2026-04-07-member-card-edit.md
Normal 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)` — 创建或更新 membership,status 自动计算
|
||||||
|
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 类型生效
|
||||||
186
docs/superpowers/specs/2026-04-07-member-card-edit-design.md
Normal file
186
docs/superpowers/specs/2026-04-07-member-card-edit-design.md
Normal 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 清空卡
|
||||||
|
|
||||||
|
- 需二次确认
|
||||||
|
- 成功后弹窗关闭,列表自动刷新
|
||||||
298
packages/app/src/components/AboutSection.vue
Normal file
298
packages/app/src/components/AboutSection.vue
Normal 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>
|
||||||
@@ -105,13 +105,13 @@
|
|||||||
<view class="btn-outline" @tap="handleCancel">
|
<view class="btn-outline" @tap="handleCancel">
|
||||||
<text class="btn-outline-text">取消</text>
|
<text class="btn-outline-text">取消</text>
|
||||||
</view>
|
</view>
|
||||||
<view
|
<button
|
||||||
class="btn-confirm"
|
class="btn-confirm"
|
||||||
:class="{ disabled: !selectedMembershipId }"
|
:class="{ disabled: !selectedMembershipId }"
|
||||||
@tap="handleConfirm"
|
@tap="handleConfirm"
|
||||||
>
|
>
|
||||||
<text class="btn-confirm-text">确认预约</text>
|
<text class="btn-confirm-text">确认预约</text>
|
||||||
</view>
|
</button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -120,6 +120,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pilates/shared'
|
import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pilates/shared'
|
||||||
|
import { requestBookingCreatedSubscriptionMessage } from '../utils/wechat-subscription'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
visible: boolean
|
visible: boolean
|
||||||
@@ -134,6 +135,7 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const selectedMembershipId = ref<string>('')
|
const selectedMembershipId = ref<string>('')
|
||||||
|
const requestingSubscribe = ref(false)
|
||||||
|
|
||||||
// Auto-select the first membership when popup opens or memberships list changes
|
// Auto-select the first membership when popup opens or memberships list changes
|
||||||
watch(
|
watch(
|
||||||
@@ -150,8 +152,22 @@ const selectedMembership = computed(() =>
|
|||||||
props.memberships.find((m) => m.id === selectedMembershipId.value) ?? null,
|
props.memberships.find((m) => m.id === selectedMembershipId.value) ?? null,
|
||||||
)
|
)
|
||||||
|
|
||||||
function handleConfirm() {
|
async function handleConfirm() {
|
||||||
if (!props.timeSlot || !selectedMembershipId.value) return
|
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', {
|
emit('confirm', {
|
||||||
timeSlotId: props.timeSlot.id,
|
timeSlotId: props.timeSlot.id,
|
||||||
membershipId: selectedMembershipId.value,
|
membershipId: selectedMembershipId.value,
|
||||||
|
|||||||
@@ -1,51 +1,49 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="brand-banner">
|
<view class="brand-banner">
|
||||||
<!-- Background image layer -->
|
<!-- Background image layer with blur -->
|
||||||
<image
|
<image
|
||||||
v-if="studioInfo?.bannerUrl"
|
|
||||||
class="banner-bg"
|
class="banner-bg"
|
||||||
:src="studioInfo.bannerUrl"
|
:src="bannerImage"
|
||||||
mode="aspectFill"
|
mode="aspectFill"
|
||||||
/>
|
/>
|
||||||
<!-- Dark overlay for readability -->
|
<!-- Dark overlay for readability -->
|
||||||
<view class="banner-overlay" />
|
<view class="banner-overlay" />
|
||||||
|
|
||||||
<!-- Status bar spacer -->
|
|
||||||
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }" />
|
|
||||||
|
|
||||||
<!-- Centered content -->
|
<!-- Centered content -->
|
||||||
<view class="banner-content">
|
<view class="banner-content">
|
||||||
<!-- Circular logo -->
|
<!-- Circular logo -->
|
||||||
<view class="logo-circle">
|
<view class="logo-circle">
|
||||||
<image
|
<image
|
||||||
v-if="studioInfo?.logo"
|
v-if="logoImage"
|
||||||
class="logo-img"
|
class="logo-img"
|
||||||
:src="studioInfo.logo"
|
:src="logoImage"
|
||||||
mode="aspectFit"
|
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>
|
</view>
|
||||||
|
|
||||||
<!-- Studio name -->
|
<!-- Studio name -->
|
||||||
<text class="studio-name">{{ studioInfo?.name || 'Focus Core' }}</text>
|
<text class="studio-name">{{ studioName }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { computed } from 'vue'
|
||||||
import type { StudioConfig } from '@mp-pilates/shared'
|
import type { StudioConfig } from '@mp-pilates/shared'
|
||||||
import { getSystemLayout } from '../utils/system'
|
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
studioInfo: StudioConfig | null
|
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(() => {
|
const bannerImage = computed(() => props.studioInfo?.bannerUrl || fallbackBannerImage)
|
||||||
statusBarHeight.value = getSystemLayout().statusBarHeight
|
const logoImage = computed(() => props.studioInfo?.logo || fallbackLogoImage)
|
||||||
})
|
const studioName = computed(() => props.studioInfo?.name || 'Focus Core')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -63,6 +61,8 @@ onMounted(() => {
|
|||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
filter: blur(2px);
|
||||||
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.banner-overlay {
|
.banner-overlay {
|
||||||
@@ -74,11 +74,6 @@ onMounted(() => {
|
|||||||
background: rgba($primary-dark, 0.25);
|
background: rgba($primary-dark, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-bar {
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner-content {
|
.banner-content {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
@@ -86,7 +81,7 @@ onMounted(() => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding-top: 40rpx;
|
padding-top: 120rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-circle {
|
.logo-circle {
|
||||||
@@ -102,15 +97,22 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.logo-img {
|
.logo-img {
|
||||||
width: 160rpx;
|
width: 200rpx;
|
||||||
height: 160rpx;
|
height: 200rpx;
|
||||||
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-placeholder {
|
.logo-placeholder {
|
||||||
|
width: 200rpx;
|
||||||
|
height: 200rpx;
|
||||||
|
border-radius: 50%;
|
||||||
font-size: 64rpx;
|
font-size: 64rpx;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: #333;
|
color: #333;
|
||||||
letter-spacing: 4rpx;
|
letter-spacing: 4rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.studio-name {
|
.studio-name {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
:key="i"
|
:key="i"
|
||||||
class="card-row skeleton-row"
|
class="card-row skeleton-row"
|
||||||
>
|
>
|
||||||
<view class="skeleton-thumb" />
|
<view class="skeleton-card-cover" />
|
||||||
<view class="skeleton-info">
|
<view class="skeleton-info">
|
||||||
<view class="skeleton-line skeleton-line--title" />
|
<view class="skeleton-line skeleton-line--title" />
|
||||||
<view class="skeleton-line skeleton-line--sub" />
|
<view class="skeleton-line skeleton-line--sub" />
|
||||||
@@ -30,30 +30,45 @@
|
|||||||
class="card-row"
|
class="card-row"
|
||||||
@tap="goToDetail(card.id)"
|
@tap="goToDetail(card.id)"
|
||||||
>
|
>
|
||||||
<!-- Thumbnail -->
|
<!-- Card Cover — image if available, gradient fallback -->
|
||||||
<view class="card-thumb" :class="thumbClass(card)">
|
<view class="card-cover" :class="card.coverUrl ? '' : getCardCoverClass(card.type)">
|
||||||
<view class="thumb-fallback">
|
<image
|
||||||
<text class="thumb-name">{{ truncate(card.name, 8) }}</text>
|
v-if="card.coverUrl"
|
||||||
<text class="thumb-price">¥{{ formatPrice(card.price) }}</text>
|
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>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Card info -->
|
<!-- Arrow -->
|
||||||
<view class="card-info">
|
<text class="card-arrow">›</text>
|
||||||
<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>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -67,22 +82,30 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import type { CardType } from '@mp-pilates/shared'
|
import type { CardType } from '@mp-pilates/shared'
|
||||||
import { CardTypeCategory } from '@mp-pilates/shared'
|
|
||||||
import { get } from '../utils/request'
|
import { get } from '../utils/request'
|
||||||
import { formatPrice } from '../utils/format'
|
import { formatPrice, getCardCoverClass } from '../utils/format'
|
||||||
|
|
||||||
const cardTypes = ref<CardType[]>([])
|
const cardTypes = ref<CardType[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const hasLoaded = ref(false)
|
||||||
|
|
||||||
async function fetchCardTypes() {
|
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 {
|
try {
|
||||||
const result = await get<CardType[]>('/membership/card-types')
|
const result = await get<CardType[]>('/membership/card-types')
|
||||||
cardTypes.value = result
|
cardTypes.value = result
|
||||||
.filter((c) => c.isActive)
|
.filter((c) => c.isActive)
|
||||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||||
|
hasLoaded.value = true
|
||||||
} catch {
|
} 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 {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -98,26 +121,18 @@ function goToDetail(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function goToAllCards() {
|
function goToAllCards() {
|
||||||
// Navigate to all cards page or scroll behavior
|
|
||||||
uni.navigateTo({ url: '/pages/card/detail?showAll=1' })
|
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>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.card-shop {
|
.card-shop {
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
margin-top: 16rpx;
|
margin: 16rpx 24rpx 0;
|
||||||
padding-bottom: 20rpx;
|
padding-bottom: 20rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Section header ── */
|
/* ── Section header ── */
|
||||||
@@ -150,132 +165,170 @@ function truncate(str: string, maxLen: number): string {
|
|||||||
.card-row {
|
.card-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 24rpx;
|
gap: 20rpx;
|
||||||
padding: 24rpx 0;
|
padding: 16rpx 0;
|
||||||
border-bottom: 1rpx solid #f0f0f0;
|
border-bottom: 1rpx solid rgba($brand-color, 0.08);
|
||||||
|
|
||||||
&:last-child {
|
&:last-child {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Thumbnail ── */
|
/* ══════════════════════════════════════════════════════════
|
||||||
.card-thumb {
|
CARD COVER — Clean minimal design
|
||||||
|
══════════════════════════════════════════════════════════ */
|
||||||
|
.card-cover {
|
||||||
width: 200rpx;
|
width: 200rpx;
|
||||||
height: 140rpx;
|
height: 130rpx;
|
||||||
border-radius: 12rpx;
|
border-radius: 16rpx;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thumb-fallback {
|
.card-cover-img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8rpx;
|
|
||||||
padding: 12rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.thumb--times .thumb-fallback {
|
/* Decorative circles */
|
||||||
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 {
|
/* Card cover backgrounds */
|
||||||
background: linear-gradient(135deg, #6c3483, #9b59b6);
|
.cover--times {
|
||||||
|
background: linear-gradient(135deg, #E8D5C4 0%, #D4BFA8 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.thumb--trial .thumb-fallback {
|
.cover--duration {
|
||||||
background: linear-gradient(135deg, #5a7a8a, $primary-dark);
|
background: linear-gradient(135deg, #D8C8DC 0%, #C4AECB 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.thumb-name {
|
.cover--trial {
|
||||||
font-size: 22rpx;
|
background: linear-gradient(135deg, #C8D8D2 0%, #A9C4BC 100%);
|
||||||
font-weight: 600;
|
|
||||||
color: #ffffff;
|
|
||||||
text-align: center;
|
|
||||||
line-height: 1.3;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.thumb-price {
|
/* ── Card info — matches card-cover height ── */
|
||||||
font-size: 24rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Card info ── */
|
|
||||||
.card-info {
|
.card-info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
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 {
|
.card-name {
|
||||||
display: block;
|
|
||||||
font-size: 30rpx;
|
font-size: 30rpx;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
color: #222;
|
color: $text-primary;
|
||||||
margin-bottom: 8rpx;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
letter-spacing: 0.5rpx;
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-validity {
|
.card-validity {
|
||||||
display: block;
|
font-size: 23rpx;
|
||||||
font-size: 24rpx;
|
color: $text-secondary;
|
||||||
color: #999;
|
line-height: 1.2;
|
||||||
margin-bottom: 12rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.price-row {
|
.card-times {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 4rpx;
|
gap: 4rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.price-label {
|
.card-times-value {
|
||||||
font-size: 24rpx;
|
font-size: 34rpx;
|
||||||
color: #e53935;
|
font-weight: 800;
|
||||||
|
color: $brand-color;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.price-symbol {
|
.card-times-unit {
|
||||||
font-size: 24rpx;
|
font-size: 20rpx;
|
||||||
color: #e53935;
|
color: $text-secondary;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.price-current {
|
.price-current {
|
||||||
font-size: 40rpx;
|
font-size: 32rpx;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: #e53935;
|
color: $brand-color;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.price-original {
|
.price-original {
|
||||||
font-size: 22rpx;
|
font-size: 20rpx;
|
||||||
color: #bbb;
|
color: $text-hint;
|
||||||
text-decoration: line-through;
|
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 ── */
|
||||||
.skeleton-row {
|
.skeleton-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 24rpx;
|
gap: 20rpx;
|
||||||
padding: 24rpx 0;
|
padding: 20rpx 0;
|
||||||
border-bottom: 1rpx solid #f0f0f0;
|
border-bottom: 1rpx solid rgba($brand-color, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeleton-thumb {
|
.skeleton-card-cover {
|
||||||
width: 200rpx;
|
width: 240rpx;
|
||||||
height: 140rpx;
|
height: 130rpx;
|
||||||
border-radius: 12rpx;
|
border-radius: 16rpx;
|
||||||
flex-shrink: 0;
|
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%;
|
background-size: 400% 100%;
|
||||||
animation: shimmer 1.4s infinite;
|
animation: shimmer 1.4s infinite;
|
||||||
}
|
}
|
||||||
@@ -290,12 +343,12 @@ function truncate(str: string, maxLen: number): string {
|
|||||||
.skeleton-line {
|
.skeleton-line {
|
||||||
height: 24rpx;
|
height: 24rpx;
|
||||||
border-radius: 6rpx;
|
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%;
|
background-size: 400% 100%;
|
||||||
animation: shimmer 1.4s infinite;
|
animation: shimmer 1.4s infinite;
|
||||||
|
|
||||||
&--title {
|
&--title {
|
||||||
width: 70%;
|
width: 60%;
|
||||||
height: 30rpx;
|
height: 30rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,7 +357,7 @@ function truncate(str: string, maxLen: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&--price {
|
&--price {
|
||||||
width: 50%;
|
width: 45%;
|
||||||
height: 36rpx;
|
height: 36rpx;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -319,6 +372,6 @@ function truncate(str: string, maxLen: number): string {
|
|||||||
|
|
||||||
.empty-text {
|
.empty-text {
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
color: #bbb;
|
color: $text-hint;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -39,7 +39,14 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function handleBack() {
|
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>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="date-selector">
|
<view class="date-selector" :class="`date-selector--${variant}`">
|
||||||
<scroll-view class="scroll" scroll-x enhanced :show-scrollbar="false">
|
<scroll-view class="scroll" scroll-x enhanced :show-scrollbar="false">
|
||||||
<view class="track">
|
<view class="track">
|
||||||
<view
|
<view
|
||||||
v-for="item in dateRange"
|
v-for="item in dateRange"
|
||||||
:key="item.date"
|
:key="item.date"
|
||||||
class="date-item"
|
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)"
|
@tap="handleSelect(item.date)"
|
||||||
>
|
>
|
||||||
<text class="weekday">{{ item.isToday ? '今天' : item.weekday }}</text>
|
<text class="weekday">{{ item.isToday ? '今天' : item.weekday }}</text>
|
||||||
@@ -25,6 +28,7 @@ import { getDateRange } from '../utils/format'
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
modelValue: string
|
modelValue: string
|
||||||
|
variant?: 'default' | 'booking'
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
@@ -47,6 +51,8 @@ function handleSelect(date: string) {
|
|||||||
emit('update:modelValue', date)
|
emit('update:modelValue', date)
|
||||||
emit('select', date)
|
emit('select', date)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const variant = computed(() => props.variant ?? 'default')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -55,6 +61,11 @@ function handleSelect(date: string) {
|
|||||||
padding: 16rpx 0 20rpx;
|
padding: 16rpx 0 20rpx;
|
||||||
border-bottom: 1rpx solid $primary-border;
|
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 {
|
.scroll {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -121,6 +132,40 @@ function handleSelect(date: string) {
|
|||||||
font-weight: 600;
|
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>
|
</style>
|
||||||
|
|||||||
438
packages/app/src/components/FlashSaleSection.vue
Normal file
438
packages/app/src/components/FlashSaleSection.vue
Normal 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>
|
||||||
@@ -40,28 +40,38 @@ interface MenuItem {
|
|||||||
path?: string
|
path?: string
|
||||||
isAdmin?: boolean
|
isAdmin?: boolean
|
||||||
badge?: string
|
badge?: string
|
||||||
action?: 'clear' | 'about'
|
action?: 'clear'
|
||||||
requireAuth?: boolean
|
requireAuth?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
requireAuth?: boolean
|
requireAuth?: boolean
|
||||||
|
activeMembershipCount?: number
|
||||||
|
upcomingBookingCount?: number
|
||||||
|
inviteShareEligible?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'clear-cache'): void
|
(e: 'clear-cache'): void
|
||||||
(e: 'about'): void
|
|
||||||
(e: 'require-login'): void
|
(e: 'require-login'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const menuItems = computed<MenuItem[]>(() => {
|
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[] = [
|
const items: MenuItem[] = [
|
||||||
{
|
{
|
||||||
key: 'membership',
|
key: 'membership',
|
||||||
type: 'item',
|
type: 'item',
|
||||||
title: '我的会员卡',
|
title: '我的会员卡',
|
||||||
path: '/pages/profile/membership',
|
path: '/pages/profile/membership',
|
||||||
|
badge: membershipBadge,
|
||||||
requireAuth: true,
|
requireAuth: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -69,8 +79,28 @@ const menuItems = computed<MenuItem[]>(() => {
|
|||||||
type: 'item',
|
type: 'item',
|
||||||
title: '我的预约',
|
title: '我的预约',
|
||||||
path: '/pages/profile/bookings',
|
path: '/pages/profile/bookings',
|
||||||
|
badge: bookingBadge,
|
||||||
requireAuth: true,
|
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',
|
key: 'info',
|
||||||
type: 'item',
|
type: 'item',
|
||||||
@@ -88,12 +118,6 @@ const menuItems = computed<MenuItem[]>(() => {
|
|||||||
title: '清除缓存',
|
title: '清除缓存',
|
||||||
action: 'clear',
|
action: 'clear',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'about',
|
|
||||||
type: 'item',
|
|
||||||
title: '关于我们',
|
|
||||||
action: 'about',
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
if (props.isAdmin) {
|
if (props.isAdmin) {
|
||||||
@@ -118,8 +142,6 @@ function handleTap(item: MenuItem) {
|
|||||||
}
|
}
|
||||||
if (item.action === 'clear') {
|
if (item.action === 'clear') {
|
||||||
emit('clear-cache')
|
emit('clear-cache')
|
||||||
} else if (item.action === 'about') {
|
|
||||||
emit('about')
|
|
||||||
} else if (item.path) {
|
} else if (item.path) {
|
||||||
uni.navigateTo({ url: 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 {
|
&--info {
|
||||||
background: rgba($brand-color, 0.06);
|
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 {
|
&--admin {
|
||||||
background: rgba($accent-color, 0.12);
|
background: rgba($accent-color, 0.12);
|
||||||
@@ -346,11 +394,17 @@ function handleTap(item: MenuItem) {
|
|||||||
|
|
||||||
&__badge {
|
&__badge {
|
||||||
font-size: 22rpx;
|
font-size: 22rpx;
|
||||||
color: #ffffff;
|
line-height: 1;
|
||||||
background: $error-color;
|
font-weight: 600;
|
||||||
border-radius: 20rpx;
|
color: #8f6759;
|
||||||
padding: 2rpx 12rpx;
|
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;
|
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 {
|
&__arrow {
|
||||||
|
|||||||
@@ -1,98 +1,54 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="quick-entry">
|
<view class="quick-entry">
|
||||||
<!-- ① Not logged in -->
|
<!-- ① Not logged in -->
|
||||||
<view v-if="!userStore.loggedIn" class="entry-card login-card" @tap="handleLogin">
|
<view v-if="!userStore.loggedIn" class="entry-pill pill-login" @tap="handleLogin">
|
||||||
<view class="entry-content">
|
<view class="pill-dot dot-login" />
|
||||||
<view class="entry-left">
|
<text class="pill-label">欢迎来到工作室</text>
|
||||||
<view class="entry-icon-wrap login-icon">
|
<view class="pill-action action-login">
|
||||||
<view class="icon-user" />
|
<text class="pill-action-text">微信登录</text>
|
||||||
</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>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- ② Logged in, no memberships at all → new user -->
|
<!-- ② Logged in, no memberships → new user -->
|
||||||
<view
|
<view
|
||||||
v-else-if="userStore.loggedIn && userStore.memberships.length === 0"
|
v-else-if="userStore.loggedIn && userStore.memberships.length === 0"
|
||||||
class="entry-card trial-card"
|
class="entry-pill pill-trial"
|
||||||
@tap="handleTrialEntry"
|
@tap="handleTrialEntry"
|
||||||
>
|
>
|
||||||
<view class="entry-content">
|
<view class="pill-tag tag-trial">体验</view>
|
||||||
<view class="entry-left">
|
<text class="pill-label">首次体验专属课程</text>
|
||||||
<view class="entry-icon-wrap trial-icon">
|
<view class="pill-action action-trial">
|
||||||
<view class="icon-star" />
|
<text class="pill-action-text">预约体验课</text>
|
||||||
</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>
|
</view>
|
||||||
<view class="card-badge trial-badge">新会员专享</view>
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- ③ Has valid active card + running low warning -->
|
<!-- ③ Has valid active card -->
|
||||||
<template v-else-if="userStore.hasValidMembership">
|
<template v-else-if="userStore.hasValidMembership">
|
||||||
<view class="entry-card active-card" @tap="handleBooking">
|
<view class="entry-pill pill-active" @tap="handleBooking">
|
||||||
<view class="entry-content">
|
<view class="pill-dot dot-active" />
|
||||||
<view class="entry-left">
|
<text class="pill-label pill-label-active">{{ activeMembershipLabel }}</text>
|
||||||
<view class="entry-icon-wrap active-icon">
|
<view class="pill-action action-book">
|
||||||
<view class="icon-clock" />
|
<text class="pill-action-text">约课</text>
|
||||||
</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>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Renew reminder if running low -->
|
<!-- Running low: thin accent strip -->
|
||||||
<view v-if="isRunningLow" class="renew-tip" @tap="scrollToCardShop">
|
<view v-if="isRunningLow" class="renew-strip" @tap="scrollToCardShop">
|
||||||
<view class="renew-tip-icon">
|
<text class="renew-strip-text">仅剩 {{ lowestRemainingTimes }} 次 · 续卡保持节奏</text>
|
||||||
<view class="icon-warning" />
|
<text class="renew-strip-arrow">›</text>
|
||||||
</view>
|
|
||||||
<text class="renew-tip-text">课次即将用完,点击续卡保持练习节奏</text>
|
|
||||||
<text class="renew-tip-action">续卡 ›</text>
|
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- ④ Has memberships but none active → buy card -->
|
<!-- ④ Has memberships but none active → buy card -->
|
||||||
<view
|
<view
|
||||||
v-else
|
v-else
|
||||||
class="entry-card expired-card"
|
class="entry-pill pill-expired"
|
||||||
@tap="scrollToCardShop"
|
@tap="scrollToCardShop"
|
||||||
>
|
>
|
||||||
<view class="entry-content">
|
<view class="pill-dot dot-expired" />
|
||||||
<view class="entry-left">
|
<text class="pill-label">会员卡已到期</text>
|
||||||
<view class="entry-icon-wrap expired-icon">
|
<view class="pill-action action-renew">
|
||||||
<view class="icon-card" />
|
<text class="pill-action-text">续卡</text>
|
||||||
</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>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -102,6 +58,7 @@
|
|||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useUserStore } from '../stores/user'
|
import { useUserStore } from '../stores/user'
|
||||||
import { CardTypeCategory } from '@mp-pilates/shared'
|
import { CardTypeCategory } from '@mp-pilates/shared'
|
||||||
|
import { getErrorMessage } from '../utils/auth'
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'scroll-to-card-shop'): void
|
(e: 'scroll-to-card-shop'): void
|
||||||
@@ -114,19 +71,15 @@ async function handleLogin() {
|
|||||||
if (loading.value) return
|
if (loading.value) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
await userStore.login()
|
await userStore.loginWithSetup()
|
||||||
await userStore.fetchMemberships()
|
} catch (err: unknown) {
|
||||||
// 登录成功后跳转到个人中心,让用户完善信息
|
uni.showToast({ title: getErrorMessage(err, '登录失败,请重试'), icon: 'none' })
|
||||||
uni.navigateTo({ url: '/pages/profile/info' })
|
|
||||||
} catch {
|
|
||||||
uni.showToast({ title: '登录失败,请重试', icon: 'none' })
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTrialEntry() {
|
function handleTrialEntry() {
|
||||||
// Navigate to the first TRIAL card detail page
|
|
||||||
uni.navigateTo({ url: '/pages/card/detail?trial=1' })
|
uni.navigateTo({ url: '/pages/card/detail?trial=1' })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,22 +91,20 @@ function scrollToCardShop() {
|
|||||||
emit('scroll-to-card-shop')
|
emit('scroll-to-card-shop')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Computed: label for the active membership
|
|
||||||
const activeMembershipLabel = computed(() => {
|
const activeMembershipLabel = computed(() => {
|
||||||
const active = userStore.activeMemberships
|
const active = userStore.activeMemberships
|
||||||
if (!active.length) return ''
|
if (!active.length) return ''
|
||||||
const m = active[0]
|
const m = active[0]
|
||||||
const cardName = m.cardType.name
|
const cardName = m.cardType.name
|
||||||
if (m.cardType.type === CardTypeCategory.TIMES && m.remainingTimes !== null) {
|
if (m.cardType.type === CardTypeCategory.TIMES && m.remainingTimes !== null) {
|
||||||
return `${cardName} · 剩余 ${m.remainingTimes} 次`
|
return `${cardName} · 剩余 ${m.remainingTimes} 次`
|
||||||
}
|
}
|
||||||
const expire = new Date(m.expireDate)
|
const expire = new Date(m.expireDate)
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
const daysLeft = Math.ceil((expire.getTime() - today.getTime()) / 86400000)
|
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(() => {
|
const isRunningLow = computed(() => {
|
||||||
return userStore.activeMemberships.some(
|
return userStore.activeMemberships.some(
|
||||||
(m) =>
|
(m) =>
|
||||||
@@ -177,295 +128,159 @@ const lowestRemainingTimes = computed(() => {
|
|||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.quick-entry {
|
.quick-entry {
|
||||||
margin: 24rpx 24rpx 0;
|
padding: 20rpx 24rpx 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entry-card {
|
/* ── Pill base ── */
|
||||||
position: relative;
|
.entry-pill {
|
||||||
border-radius: 16rpx;
|
display: flex;
|
||||||
padding: 36rpx 32rpx;
|
align-items: center;
|
||||||
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.08);
|
height: 80rpx;
|
||||||
overflow: hidden;
|
border-radius: 40rpx;
|
||||||
|
padding: 0 8rpx 0 24rpx;
|
||||||
|
gap: 16rpx;
|
||||||
|
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-card {
|
/* ── Pill variants ── */
|
||||||
background: linear-gradient(135deg, #1a1a2e 0%, #2d2d5e 100%);
|
.pill-login {
|
||||||
|
background: #1a1a2e;
|
||||||
}
|
}
|
||||||
|
|
||||||
.trial-card {
|
.pill-trial {
|
||||||
background: linear-gradient(135deg, #2d2d5e 0%, #4a3f7a 100%);
|
background: linear-gradient(135deg, #2d2d5e 0%, #4a3f7a 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.active-card {
|
.pill-active {
|
||||||
background: linear-gradient(135deg, #2a3a4a 0%, #1a2a3a 100%);
|
background: #ffffff;
|
||||||
|
border: 1rpx solid rgba(0, 0, 0, 0.06);
|
||||||
|
box-shadow: 0 2rpx 16rpx rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.expired-card {
|
.pill-expired {
|
||||||
background: linear-gradient(135deg, #4a4a4a 0%, #2a2a2a 100%);
|
background: #f5f5f5;
|
||||||
|
border: 1rpx solid rgba(0, 0, 0, 0.04);
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entry-content {
|
/* ── Status dot ── */
|
||||||
display: flex;
|
.pill-dot {
|
||||||
align-items: center;
|
width: 14rpx;
|
||||||
justify-content: space-between;
|
height: 14rpx;
|
||||||
gap: 24rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entry-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 28rpx;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entry-icon-wrap {
|
|
||||||
width: 88rpx;
|
|
||||||
height: 88rpx;
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
position: relative;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-icon {
|
.dot-login {
|
||||||
background: rgba(255, 255, 255, 0.12);
|
background: $primary-color;
|
||||||
|
box-shadow: 0 0 8rpx rgba($primary-color, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.trial-icon {
|
.dot-active {
|
||||||
background: rgba(255, 215, 0, 0.2);
|
background: #34c759;
|
||||||
|
box-shadow: 0 0 8rpx rgba(52, 199, 89, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.active-icon {
|
.dot-expired {
|
||||||
background: rgba(168, 196, 206, 0.25);
|
background: #aaa;
|
||||||
}
|
}
|
||||||
|
|
||||||
.expired-icon {
|
/* ── Tag (trial only) ── */
|
||||||
background: rgba(255, 255, 255, 0.12);
|
.pill-tag {
|
||||||
}
|
font-size: 20rpx;
|
||||||
|
font-weight: 700;
|
||||||
/* ── Icon shapes (pure CSS) ── */
|
padding: 4rpx 14rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
/* User icon: head + shoulders */
|
flex-shrink: 0;
|
||||||
.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;
|
|
||||||
letter-spacing: 1rpx;
|
letter-spacing: 1rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entry-subtitle {
|
.tag-trial {
|
||||||
display: block;
|
background: rgba(255, 215, 0, 0.25);
|
||||||
font-size: 24rpx;
|
color: #ffd700;
|
||||||
color: rgba(255, 255, 255, 0.6);
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
flex-shrink: 0;
|
||||||
padding: 18rpx 36rpx;
|
height: 60rpx;
|
||||||
border-radius: 40rpx;
|
padding: 0 28rpx;
|
||||||
|
border-radius: 30rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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 {
|
.pill-action-text {
|
||||||
font-size: 28rpx;
|
font-size: 24rpx;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
position: relative;
|
line-height: 1;
|
||||||
z-index: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-btn,
|
.action-login {
|
||||||
.trial-btn,
|
|
||||||
.book-btn {
|
|
||||||
background: $primary-color;
|
background: $primary-color;
|
||||||
|
.pill-action-text { color: #1a1a2e; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.renew-btn {
|
.action-trial {
|
||||||
background: #666;
|
background: rgba(255, 215, 0, 0.2);
|
||||||
|
.pill-action-text { color: #ffd700; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-btn .entry-btn-text,
|
.action-book {
|
||||||
.trial-btn .entry-btn-text,
|
background: #1a1a2e;
|
||||||
.book-btn .entry-btn-text,
|
.pill-action-text { color: #fff; }
|
||||||
.renew-btn .entry-btn-text {
|
|
||||||
color: #1a1a2e;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Corner badge */
|
.action-renew {
|
||||||
.card-badge {
|
background: #e0e0e0;
|
||||||
position: absolute;
|
.pill-action-text { color: #555; }
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
padding: 8rpx 20rpx;
|
|
||||||
font-size: 20rpx;
|
|
||||||
font-weight: 600;
|
|
||||||
border-radius: 0 16rpx 0 16rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.trial-badge {
|
/* ── Renew strip (running low) ── */
|
||||||
background: linear-gradient(135deg, #ffd700, #ffaa00);
|
.renew-strip {
|
||||||
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;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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 {
|
.renew-strip-text {
|
||||||
flex: 1;
|
font-size: 22rpx;
|
||||||
font-size: 24rpx;
|
font-weight: 500;
|
||||||
color: #a0622a;
|
color: #fff;
|
||||||
line-height: 1.4;
|
letter-spacing: 0.5rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.renew-tip-action {
|
.renew-strip-arrow {
|
||||||
font-size: 24rpx;
|
font-size: 28rpx;
|
||||||
color: $primary-dark;
|
font-weight: 700;
|
||||||
font-weight: 600;
|
color: rgba(255, 255, 255, 0.8);
|
||||||
flex-shrink: 0;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,64 +1,94 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="slot-card" :class="{ 'slot-card--booked': timeSlot.isBookedByMe }">
|
<view class="slot-card-wrapper" :class="[`status-${statusClass}`]" @tap="emit('cardTap', timeSlot)">
|
||||||
<!-- Booked accent bar -->
|
<!-- Ticket background image -->
|
||||||
<view v-if="timeSlot.isBookedByMe" class="booked-bar" />
|
<image
|
||||||
|
class="ticket-bg"
|
||||||
|
src="/static/courseBg.png"
|
||||||
|
mode="aspectFill"
|
||||||
|
/>
|
||||||
|
|
||||||
<view class="slot-main">
|
<!-- Card content overlay -->
|
||||||
<!-- Left: Time column -->
|
<view class="ticket-content">
|
||||||
<view class="slot-time-col">
|
<!-- ── Top section: Time row (like flight ticket) ── -->
|
||||||
<text class="slot-start">{{ timeSlot.startTime.slice(0, 5) }}</text>
|
<view class="ticket-top">
|
||||||
<view class="time-divider" />
|
<!-- Left: Start time -->
|
||||||
<text class="slot-end">{{ timeSlot.endTime.slice(0, 5) }}</text>
|
<view class="time-block">
|
||||||
</view>
|
<text class="time-main">{{ startTimeDisplay }}</text>
|
||||||
|
<text class="time-label">{{ timeSlot.date }}</text>
|
||||||
<!-- Center: Info -->
|
|
||||||
<view class="slot-info">
|
|
||||||
<view class="slot-title-row">
|
|
||||||
<text class="slot-title">普拉提私教</text>
|
|
||||||
<text class="slot-duration">{{ durationMin }}分钟</text>
|
|
||||||
</view>
|
</view>
|
||||||
<view class="slot-meta">
|
|
||||||
<view class="slot-capacity" :class="capacityClass">
|
<!-- Center: Duration + icon -->
|
||||||
<text class="capacity-dot" />
|
<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>
|
<text class="capacity-text">{{ capacityLabel }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Right: Action -->
|
<!-- ── Dashed tear-off line ── -->
|
||||||
<view class="slot-action">
|
<view class="tear-line" />
|
||||||
<!-- 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>
|
|
||||||
|
|
||||||
<!-- OPEN + booked by me -->
|
<!-- ── Bottom section: Course name + Action ── -->
|
||||||
<template v-else-if="timeSlot.status === TimeSlotStatus.OPEN && timeSlot.isBookedByMe">
|
<view class="ticket-bottom">
|
||||||
<view class="booked-badge-col">
|
<view class="course-info">
|
||||||
<view class="badge-booked">
|
<text class="course-name">普拉提私教</text>
|
||||||
<text class="badge-text">已预约</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>
|
||||||
<view class="btn-cancel" @tap.stop="emit('cancel', timeSlot)">
|
</template>
|
||||||
<text class="btn-cancel-text">取消预约</text>
|
|
||||||
|
<!-- 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>
|
||||||
</view>
|
</template>
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- FULL -->
|
<!-- OPEN + booked by me -->
|
||||||
<template v-else-if="timeSlot.status === TimeSlotStatus.FULL">
|
<template v-else-if="timeSlot.status === TimeSlotStatus.OPEN && timeSlot.isBookedByMe">
|
||||||
<view class="btn btn-full">
|
<view class="action-badge badge-booked">
|
||||||
<text class="btn-text">已约满</text>
|
<text>{{ myBookingLabel }}</text>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
<view class="cancel-link" @tap.stop="emit('cancel', timeSlot)">
|
||||||
|
<text>取消</text>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- CLOSED -->
|
<!-- FULL -->
|
||||||
<template v-else>
|
<template v-else-if="timeSlot.status === TimeSlotStatus.FULL">
|
||||||
<view class="btn btn-closed">
|
<view class="action-badge badge-full">
|
||||||
<text class="btn-text">已关闭</text>
|
<text>已约满</text>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- CLOSED -->
|
||||||
|
<template v-else>
|
||||||
|
<view class="action-badge badge-closed">
|
||||||
|
<text>已关闭</text>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -66,8 +96,9 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TimeSlotWithBookingStatus } from '@mp-pilates/shared'
|
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 { computed } from 'vue'
|
||||||
|
import { isSlotPast } from '../utils/format'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
timeSlot: TimeSlotWithBookingStatus
|
timeSlot: TimeSlotWithBookingStatus
|
||||||
@@ -77,20 +108,30 @@ const props = defineProps<Props>()
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
book: [timeSlot: TimeSlotWithBookingStatus]
|
book: [timeSlot: TimeSlotWithBookingStatus]
|
||||||
cancel: [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 durationMin = computed(() => {
|
||||||
const [sh, sm] = props.timeSlot.startTime.split(':').map(Number)
|
const [sh, sm] = props.timeSlot.startTime.split(':').map(Number)
|
||||||
const [eh, em] = props.timeSlot.endTime.split(':').map(Number)
|
const [eh, em] = props.timeSlot.endTime.split(':').map(Number)
|
||||||
return (eh * 60 + em) - (sh * 60 + sm)
|
return (eh * 60 + em) - (sh * 60 + sm)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const myBookingLabel = computed(() => (
|
||||||
|
props.timeSlot.myBookingStatus === BookingStatus.PENDING_CONFIRMATION
|
||||||
|
? '已预约待确认'
|
||||||
|
: '已预约'
|
||||||
|
))
|
||||||
|
|
||||||
const capacityLabel = computed(() => {
|
const capacityLabel = computed(() => {
|
||||||
const { bookedCount, capacity, status } = props.timeSlot
|
const { bookedCount, capacity, status } = props.timeSlot
|
||||||
if (status === TimeSlotStatus.CLOSED) return '已关闭'
|
if (status === TimeSlotStatus.CLOSED) return '已关闭'
|
||||||
if (status === TimeSlotStatus.FULL) return '已约满'
|
if (status === TimeSlotStatus.FULL) return '已约满'
|
||||||
const remaining = capacity - bookedCount
|
const remaining = capacity - bookedCount
|
||||||
return `剩余 ${remaining} 个名额`
|
return `剩余${remaining}位`
|
||||||
})
|
})
|
||||||
|
|
||||||
const capacityClass = computed(() => {
|
const capacityClass = computed(() => {
|
||||||
@@ -100,223 +141,289 @@ const capacityClass = computed(() => {
|
|||||||
if (bookedCount >= capacity * 0.8) return 'cap-almost'
|
if (bookedCount >= capacity * 0.8) return 'cap-almost'
|
||||||
return 'cap-open'
|
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>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.slot-card {
|
/* ─── Wrapper ─── */
|
||||||
background: #fff;
|
.slot-card-wrapper {
|
||||||
border-radius: 24rpx;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.05);
|
|
||||||
position: relative;
|
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 {
|
&:active {
|
||||||
transform: scale(0.985);
|
transform: scale(0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
&--booked {
|
/* Status-based opacity */
|
||||||
background: #f0f7fb;
|
&.status-expired {
|
||||||
box-shadow: 0 4rpx 24rpx rgba($primary-dark, 0.12);
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-full,
|
||||||
|
&.status-closed {
|
||||||
|
opacity: 0.75;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.booked-bar {
|
/* ─── Ticket background image ─── */
|
||||||
|
.ticket-bg {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
|
||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
left: 0;
|
||||||
width: 8rpx;
|
width: 100%;
|
||||||
background: linear-gradient(180deg, $primary-color, $primary-dark);
|
height: 100%;
|
||||||
border-radius: 24rpx 0 0 24rpx;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slot-main {
|
/* ─── Content overlay ─── */
|
||||||
display: flex;
|
.ticket-content {
|
||||||
flex-direction: row;
|
position: relative;
|
||||||
align-items: center;
|
z-index: 1;
|
||||||
padding: 32rpx 28rpx 32rpx 36rpx;
|
|
||||||
gap: 24rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Time column ─── */
|
|
||||||
.slot-time-col {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
padding: 28rpx 40rpx 24rpx;
|
||||||
min-width: 80rpx;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.slot-start {
|
/* ═══ Top section: Time row ═══ */
|
||||||
font-size: 34rpx;
|
.ticket-top {
|
||||||
font-weight: 700;
|
display: flex;
|
||||||
color: #1a1a1a;
|
align-items: flex-start;
|
||||||
line-height: 1.2;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-divider {
|
.time-block {
|
||||||
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;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10rpx;
|
align-items: flex-start;
|
||||||
min-width: 0;
|
min-width: 100rpx;
|
||||||
|
|
||||||
|
&--right {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.slot-title-row {
|
.time-main {
|
||||||
display: flex;
|
font-size: 40rpx;
|
||||||
flex-direction: row;
|
font-weight: 800;
|
||||||
align-items: baseline;
|
color: #3a2e2a;
|
||||||
gap: 12rpx;
|
line-height: 1;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slot-title {
|
.time-label {
|
||||||
font-size: 30rpx;
|
margin-top: 8rpx;
|
||||||
font-weight: 600;
|
|
||||||
color: #1a1a1a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slot-duration {
|
|
||||||
font-size: 22rpx;
|
font-size: 22rpx;
|
||||||
color: #bbb;
|
color: #a18a82;
|
||||||
font-weight: 400;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slot-meta {
|
/* Duration center block */
|
||||||
|
.duration-block {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12rpx;
|
flex: 1;
|
||||||
|
padding: 0 16rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slot-capacity {
|
.duration-line {
|
||||||
display: inline-flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8rpx;
|
width: 100%;
|
||||||
|
margin-top: 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; }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Action ─── */
|
.line-dot {
|
||||||
.slot-action {
|
width: 10rpx;
|
||||||
|
height: 10rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ccb7ae;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.line-dash {
|
||||||
min-width: 140rpx;
|
flex: 1;
|
||||||
height: 72rpx;
|
height: 2rpx;
|
||||||
border-radius: 36rpx;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 0 32rpx;
|
margin: 0 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-text {
|
.icon-text {
|
||||||
font-size: 26rpx;
|
font-size: 22rpx;
|
||||||
font-weight: 600;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
&.btn-book {
|
.duration-text {
|
||||||
background: linear-gradient(135deg, $primary-color, $primary-dark);
|
margin-top: 6rpx;
|
||||||
box-shadow: 0 4rpx 16rpx rgba($primary-dark, 0.3);
|
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 {
|
&.cap-open {
|
||||||
opacity: 0.85;
|
background: rgba(101, 163, 126, 0.12);
|
||||||
|
|
||||||
|
.capacity-text {
|
||||||
|
color: #5d9472;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.btn-full {
|
&.cap-almost {
|
||||||
background: #fef0f0;
|
background: rgba(214, 161, 92, 0.14);
|
||||||
|
|
||||||
.btn-text { color: #ef4444; }
|
.capacity-text {
|
||||||
|
color: #b98543;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.btn-closed {
|
&.cap-full {
|
||||||
background: #f5f5f5;
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8rpx;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-booked {
|
.course-info {
|
||||||
height: 52rpx;
|
flex: 1;
|
||||||
padding: 0 24rpx;
|
}
|
||||||
background: linear-gradient(135deg, $primary-selected-bg, $primary-border);
|
|
||||||
border-radius: 26rpx;
|
.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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.badge-text {
|
.btn-book {
|
||||||
font-size: 24rpx;
|
background: linear-gradient(135deg, #d5b9ab 0%, #b98f7d 100%);
|
||||||
color: $primary-dark;
|
color: #fffaf7;
|
||||||
font-weight: 600;
|
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 {
|
.badge-booked {
|
||||||
padding: 4rpx 8rpx;
|
background: linear-gradient(135deg, rgba(247, 240, 235, 0.96), rgba(236, 225, 217, 0.98));
|
||||||
display: flex;
|
color: #8f6759;
|
||||||
align-items: center;
|
}
|
||||||
|
|
||||||
.btn-cancel-text {
|
.badge-expired {
|
||||||
font-size: 22rpx;
|
background: rgba(111, 96, 91, 0.08);
|
||||||
color: #ef4444;
|
color: #9d8f89;
|
||||||
font-weight: 400;
|
}
|
||||||
}
|
|
||||||
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -1,61 +1,78 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="studio-info">
|
<view class="studio-info">
|
||||||
<!-- Horizontal photo strip -->
|
<!-- Address + Chat row -->
|
||||||
<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 -->
|
|
||||||
<view class="location-row">
|
<view class="location-row">
|
||||||
<view class="location-left" @tap="handleAddressTap">
|
<view class="location-left" @tap="handleAddressTap">
|
||||||
<text class="location-icon">📍</text>
|
<view class="location-icon" />
|
||||||
<text class="location-text">
|
<view class="location-content">
|
||||||
{{ studioInfo?.address || '深圳市宝安区西乡街道财富港 D 座 1203D' }}
|
<text class="location-label">场馆地址</text>
|
||||||
</text>
|
<text class="location-text">
|
||||||
</view>
|
{{ studioInfo?.address || '深圳市宝安区西乡街道财富港 D 座 1203D' }}
|
||||||
<view class="phone-btn" @tap="handlePhoneTap">
|
</text>
|
||||||
<text class="phone-icon">📞</text>
|
</view>
|
||||||
</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>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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<{
|
const props = defineProps<{
|
||||||
studioInfo: StudioConfig | null
|
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) {
|
function previewPhoto(index: number) {
|
||||||
if (!props.studioInfo?.photos?.length) return
|
|
||||||
uni.previewImage({
|
uni.previewImage({
|
||||||
current: index,
|
current: index,
|
||||||
urls: props.studioInfo.photos,
|
urls: galleryPhotos.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAddressTap() {
|
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
|
uni.openLocation({
|
||||||
|
latitude,
|
||||||
if (latitude && longitude) {
|
longitude,
|
||||||
uni.openLocation({
|
name,
|
||||||
latitude,
|
address,
|
||||||
longitude,
|
fail() {
|
||||||
name: name || 'Focus Core',
|
copyAddress()
|
||||||
address,
|
},
|
||||||
fail() {
|
})
|
||||||
copyAddress()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
copyAddress()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function 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>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.studio-info {
|
.studio-info {
|
||||||
|
margin: 16rpx 24rpx 0;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
}
|
border-radius: 20rpx;
|
||||||
|
overflow: hidden;
|
||||||
/* ── Photo strip ── */
|
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Location row ── */
|
/* ── Location row ── */
|
||||||
@@ -112,7 +101,7 @@ function handlePhoneTap() {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 24rpx 32rpx 28rpx;
|
padding: 28rpx 32rpx 24rpx;
|
||||||
gap: 24rpx;
|
gap: 24rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,31 +113,154 @@ function handlePhoneTap() {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.location-icon {
|
.location-content {
|
||||||
font-size: 28rpx;
|
flex: 1;
|
||||||
flex-shrink: 0;
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #b39a92;
|
||||||
|
letter-spacing: 2rpx;
|
||||||
|
margin-bottom: 6rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.location-text {
|
.location-text {
|
||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
color: #666;
|
color: #5f5955;
|
||||||
line-height: 1.5;
|
line-height: 1.6;
|
||||||
word-break: break-all;
|
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;
|
width: 72rpx;
|
||||||
height: 72rpx;
|
height: 72rpx;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #f5f5f5;
|
background: rgba($brand-color, 0.06);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.phone-icon {
|
.chat-btn::after {
|
||||||
font-size: 36rpx;
|
border: none;
|
||||||
color: #4CAF50;
|
}
|
||||||
|
|
||||||
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="time-period-filter">
|
<view class="time-period-filter" :class="`time-period-filter--${variant}`">
|
||||||
<view
|
<view
|
||||||
v-for="tab in tabs"
|
v-for="tab in tabs"
|
||||||
:key="tab.key ?? 'all'"
|
:key="tab.key ?? 'all'"
|
||||||
class="tab-item"
|
class="tab-item"
|
||||||
:class="{ active: modelValue === tab.key }"
|
:class="[`tab-item--${variant}`, { active: modelValue === tab.key }]"
|
||||||
@tap="handleChange(tab.key)"
|
@tap="handleChange(tab.key)"
|
||||||
>
|
>
|
||||||
<text class="tab-label">{{ tab.label }}</text>
|
<text class="tab-label">{{ tab.label }}</text>
|
||||||
@@ -25,14 +25,17 @@ interface Tab {
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
modelValue: PeriodKey
|
modelValue: PeriodKey
|
||||||
|
variant?: 'default' | 'booking'
|
||||||
}
|
}
|
||||||
|
|
||||||
defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
change: [period: PeriodKey]
|
change: [period: PeriodKey]
|
||||||
'update:modelValue': [period: PeriodKey]
|
'update:modelValue': [period: PeriodKey]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const variant = computed(() => props.variant ?? 'default')
|
||||||
|
|
||||||
const tabs = computed<Tab[]>(() => [
|
const tabs = computed<Tab[]>(() => [
|
||||||
{ key: null, label: '全部' },
|
{ key: null, label: '全部' },
|
||||||
...Object.entries(TIME_PERIODS).map(([key, val]) => ({
|
...Object.entries(TIME_PERIODS).map(([key, val]) => ({
|
||||||
@@ -55,6 +58,11 @@ function handleChange(key: PeriodKey) {
|
|||||||
padding: 0 24rpx;
|
padding: 0 24rpx;
|
||||||
border-bottom: 1rpx solid $primary-border;
|
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 {
|
.tab-item {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -87,6 +95,26 @@ function handleChange(key: PeriodKey) {
|
|||||||
border-radius: 2rpx;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
v-for="booking in displayedBookings"
|
v-for="booking in displayedBookings"
|
||||||
:key="booking.id"
|
:key="booking.id"
|
||||||
class="booking-card"
|
class="booking-card"
|
||||||
|
@tap="goToBookingDetail(booking.id)"
|
||||||
>
|
>
|
||||||
<!-- Date column -->
|
<!-- Date column -->
|
||||||
<view class="date-col">
|
<view class="date-col">
|
||||||
@@ -72,6 +73,7 @@ function formatTime(timeStr: string): string {
|
|||||||
|
|
||||||
function statusLabel(status: BookingStatus): string {
|
function statusLabel(status: BookingStatus): string {
|
||||||
const map: Record<BookingStatus, string> = {
|
const map: Record<BookingStatus, string> = {
|
||||||
|
[BookingStatus.PENDING_CONFIRMATION]: '待确认',
|
||||||
[BookingStatus.CONFIRMED]: '已确认',
|
[BookingStatus.CONFIRMED]: '已确认',
|
||||||
[BookingStatus.CANCELLED]: '已取消',
|
[BookingStatus.CANCELLED]: '已取消',
|
||||||
[BookingStatus.COMPLETED]: '已完成',
|
[BookingStatus.COMPLETED]: '已完成',
|
||||||
@@ -81,6 +83,7 @@ function statusLabel(status: BookingStatus): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function statusDotClass(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.CONFIRMED) return 'dot--confirmed'
|
||||||
if (status === BookingStatus.COMPLETED) return 'dot--completed'
|
if (status === BookingStatus.COMPLETED) return 'dot--completed'
|
||||||
if (status === BookingStatus.CANCELLED) return 'dot--cancelled'
|
if (status === BookingStatus.CANCELLED) return 'dot--cancelled'
|
||||||
@@ -88,6 +91,7 @@ function statusDotClass(status: BookingStatus): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function statusTextClass(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.CONFIRMED) return 'text--confirmed'
|
||||||
if (status === BookingStatus.COMPLETED) return 'text--completed'
|
if (status === BookingStatus.COMPLETED) return 'text--completed'
|
||||||
if (status === BookingStatus.CANCELLED) return 'text--cancelled'
|
if (status === BookingStatus.CANCELLED) return 'text--cancelled'
|
||||||
@@ -97,6 +101,10 @@ function statusTextClass(status: BookingStatus): string {
|
|||||||
function goToBookings() {
|
function goToBookings() {
|
||||||
uni.navigateTo({ url: '/pages/profile/bookings' })
|
uni.navigateTo({ url: '/pages/profile/bookings' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goToBookingDetail(id: string) {
|
||||||
|
uni.navigateTo({ url: `/pages/booking/detail?id=${id}` })
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -207,6 +215,7 @@ function goToBookings() {
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dot--pending { background: #f39c12; }
|
||||||
.dot--confirmed { background: #27ae60; }
|
.dot--confirmed { background: #27ae60; }
|
||||||
.dot--completed { background: #3498db; }
|
.dot--completed { background: #3498db; }
|
||||||
.dot--cancelled { background: #e74c3c; }
|
.dot--cancelled { background: #e74c3c; }
|
||||||
@@ -217,6 +226,7 @@ function goToBookings() {
|
|||||||
color: #999;
|
color: #999;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text--pending { color: #f39c12; }
|
||||||
.text--confirmed { color: #27ae60; }
|
.text--confirmed { color: #27ae60; }
|
||||||
.text--completed { color: #3498db; }
|
.text--completed { color: #3498db; }
|
||||||
.text--cancelled { color: #e74c3c; }
|
.text--cancelled { color: #e74c3c; }
|
||||||
|
|||||||
@@ -25,14 +25,16 @@
|
|||||||
mode="aspectFill"
|
mode="aspectFill"
|
||||||
@error="onAvatarError"
|
@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>
|
||||||
<view class="user-card__info">
|
<view class="user-card__info">
|
||||||
<view class="user-card__name-row">
|
<view class="user-card__name-row">
|
||||||
<text class="user-card__nickname">{{ user!.nickname }}</text>
|
<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>
|
</view>
|
||||||
<text v-if="maskedPhone" class="user-card__phone">{{ maskedPhone }}</text>
|
<text v-if="maskedPhone" class="user-card__phone">{{ maskedPhone }}</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -75,7 +77,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import type { UserProfileResponse, UserStatsResponse, MembershipWithCardType } from '@mp-pilates/shared'
|
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<{
|
const props = defineProps<{
|
||||||
loggedIn: boolean
|
loggedIn: boolean
|
||||||
@@ -117,24 +119,25 @@ const maskedPhone = computed(() => {
|
|||||||
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
|
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
|
||||||
})
|
})
|
||||||
|
|
||||||
// Derive VIP level from active memberships count
|
|
||||||
const activeMemberships = computed(() =>
|
const activeMemberships = computed(() =>
|
||||||
props.memberships?.filter((m) => m.status === MembershipStatus.ACTIVE) ?? [],
|
props.memberships?.filter((m) => m.status === MembershipStatus.ACTIVE) ?? [],
|
||||||
)
|
)
|
||||||
|
|
||||||
const vipLevel = computed(() => {
|
const activeMembershipCount = computed(
|
||||||
const count = activeMemberships.value.length
|
() => props.user?.activeMembershipCount ?? activeMemberships.value.length,
|
||||||
if (count >= 3) return 'VIP3'
|
)
|
||||||
if (count >= 2) return 'VIP2'
|
|
||||||
if (count >= 1) return 'VIP1'
|
const hasMembership = computed(() => activeMembershipCount.value > 0)
|
||||||
return null
|
|
||||||
})
|
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
|
// Sum remaining sessions from all active time-based memberships
|
||||||
const remainingSessions = computed(() =>
|
const remainingSessions = computed(() =>
|
||||||
activeMemberships.value
|
activeMemberships.value
|
||||||
.filter((m) => m.cardType.type === 'TIMES')
|
.filter((m) => m.cardType.type === CardTypeCategory.TIMES)
|
||||||
.reduce((sum, m) => sum + m.remainingCount, 0),
|
.reduce((sum, m) => sum + toSafeCount(m.remainingTimes), 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
function onAvatarError() {
|
function onAvatarError() {
|
||||||
@@ -220,24 +223,6 @@ function handleLogin() {
|
|||||||
border: 4rpx solid rgba(255, 255, 255, 0.4);
|
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 {
|
&__info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -257,6 +242,59 @@ function handleLogin() {
|
|||||||
color: #ffffff;
|
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 {
|
&__phone {
|
||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
color: rgba(255, 255, 255, 0.75);
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "普拉提约课",
|
"name": "普拉提约课",
|
||||||
"appid": "",
|
"appid": "wx3e7a133d2305fa2c",
|
||||||
"description": "普拉提工作室约课小程序",
|
"description": "普拉提工作室约课小程序",
|
||||||
"versionName": "0.1.0",
|
"versionName": "0.1.0",
|
||||||
"versionCode": "100",
|
"versionCode": "100",
|
||||||
"transformPx": false,
|
"transformPx": false,
|
||||||
"mp-weixin": {
|
"mp-weixin": {
|
||||||
"appid": "",
|
"appid": "wx3e7a133d2305fa2c",
|
||||||
"setting": {
|
"setting": {
|
||||||
"urlCheck": false,
|
"urlCheck": false,
|
||||||
"es6": true,
|
"es6": true,
|
||||||
|
|||||||
@@ -45,12 +45,30 @@
|
|||||||
"navigationStyle": "custom"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/profile/teaching-schedule",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/profile/info",
|
"path": "pages/profile/info",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationStyle": "custom"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/profile/invite",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/teacher/detail",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/admin/index",
|
"path": "pages/admin/index",
|
||||||
"style": {
|
"style": {
|
||||||
@@ -69,12 +87,6 @@
|
|||||||
"navigationStyle": "custom"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"path": "pages/admin/week-template",
|
|
||||||
"style": {
|
|
||||||
"navigationStyle": "custom"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"path": "pages/admin/slot-adjust",
|
"path": "pages/admin/slot-adjust",
|
||||||
"style": {
|
"style": {
|
||||||
@@ -104,6 +116,18 @@
|
|||||||
"style": {
|
"style": {
|
||||||
"navigationStyle": "custom"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/admin/flash-sales",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/flash-sale/detail",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"globalStyle": {
|
"globalStyle": {
|
||||||
|
|||||||
@@ -188,6 +188,32 @@
|
|||||||
auto-height
|
auto-height
|
||||||
/>
|
/>
|
||||||
</view>
|
</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>
|
</view>
|
||||||
|
|
||||||
<!-- Action buttons -->
|
<!-- Action buttons -->
|
||||||
@@ -215,6 +241,7 @@ import CustomNavBar from '../../components/CustomNavBar.vue'
|
|||||||
import { getSystemLayout } from '../../utils/system'
|
import { getSystemLayout } from '../../utils/system'
|
||||||
import { useAdminStore } from '../../stores/admin'
|
import { useAdminStore } from '../../stores/admin'
|
||||||
import { formatPrice } from '../../utils/format'
|
import { formatPrice } from '../../utils/format'
|
||||||
|
import { uploadStudioAsset } from '../../utils/studio-upload'
|
||||||
import { CardTypeCategory } from '@mp-pilates/shared'
|
import { CardTypeCategory } from '@mp-pilates/shared'
|
||||||
import type { CardType } from '@mp-pilates/shared'
|
import type { CardType } from '@mp-pilates/shared'
|
||||||
|
|
||||||
@@ -229,6 +256,7 @@ const cardTypes = ref<CardType[]>([])
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const showModal = ref(false)
|
const showModal = ref(false)
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
|
const uploadingCover = ref(false)
|
||||||
const editTarget = ref<CardType | null>(null)
|
const editTarget = ref<CardType | null>(null)
|
||||||
|
|
||||||
const typeOptions = [
|
const typeOptions = [
|
||||||
@@ -246,6 +274,7 @@ const defaultForm = () => ({
|
|||||||
durationDaysStr: '90',
|
durationDaysStr: '90',
|
||||||
sortOrderStr: '0',
|
sortOrderStr: '0',
|
||||||
description: '',
|
description: '',
|
||||||
|
coverUrl: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const form = ref(defaultForm())
|
const form = ref(defaultForm())
|
||||||
@@ -282,6 +311,7 @@ function openEdit(ct: CardType) {
|
|||||||
durationDaysStr: String(ct.durationDays),
|
durationDaysStr: String(ct.durationDays),
|
||||||
sortOrderStr: String(ct.sortOrder),
|
sortOrderStr: String(ct.sortOrder),
|
||||||
description: ct.description ?? '',
|
description: ct.description ?? '',
|
||||||
|
coverUrl: ct.coverUrl ?? '',
|
||||||
}
|
}
|
||||||
showModal.value = true
|
showModal.value = true
|
||||||
}
|
}
|
||||||
@@ -349,6 +379,9 @@ async function submitForm() {
|
|||||||
if (form.value.description.trim()) {
|
if (form.value.description.trim()) {
|
||||||
payload.description = form.value.description.trim()
|
payload.description = form.value.description.trim()
|
||||||
}
|
}
|
||||||
|
if (form.value.coverUrl) {
|
||||||
|
payload.coverUrl = form.value.coverUrl
|
||||||
|
}
|
||||||
|
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
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 ─────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────
|
||||||
|
|
||||||
function typeLabel(ct: CardType): string {
|
function typeLabel(ct: CardType): string {
|
||||||
@@ -522,16 +634,16 @@ onMounted(fetchCardTypes)
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header--times { background: linear-gradient(90deg, #1a1a2e, #2d2d5e); }
|
.header--times { background: linear-gradient(90deg, #E8D5C4, #D4BFA8); }
|
||||||
.header--duration { background: linear-gradient(90deg, #6c3483, #9b59b6); }
|
.header--duration { background: linear-gradient(90deg, #D8C8DC, #C4AECB); }
|
||||||
.header--trial { background: linear-gradient(90deg, #5a7a8a, $primary-dark); }
|
.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; }
|
.ct-status-tag { border-radius: 20rpx; padding: 4rpx 16rpx; }
|
||||||
.tag--on { background: rgba(255,255,255,0.2); }
|
.tag--on { background: rgba(74, 64, 53, 0.1); }
|
||||||
.tag--off { background: rgba(0,0,0,0.2); }
|
.tag--off { background: rgba(74, 64, 53, 0.08); }
|
||||||
.ct-status-text { font-size: 20rpx; color: #ffffff; }
|
.ct-status-text { font-size: 20rpx; color: $brand-color; }
|
||||||
|
|
||||||
.ct-body { padding: 24rpx; }
|
.ct-body { padding: 24rpx; }
|
||||||
|
|
||||||
@@ -721,4 +833,82 @@ onMounted(fetchCardTypes)
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-confirm-text { font-size: 28rpx; font-weight: 700; color: $primary-dark; }
|
.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>
|
</style>
|
||||||
|
|||||||
863
packages/app/src/pages/admin/flash-sales.vue
Normal file
863
packages/app/src/pages/admin/flash-sales.vue
Normal 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>
|
||||||
@@ -63,21 +63,6 @@
|
|||||||
<text class="arrow-text">›</text>
|
<text class="arrow-text">›</text>
|
||||||
</view>
|
</view>
|
||||||
</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>
|
</view>
|
||||||
|
|
||||||
<!-- Section header: 会员与订单 -->
|
<!-- Section header: 会员与订单 -->
|
||||||
@@ -131,6 +116,21 @@
|
|||||||
<text class="arrow-text">›</text>
|
<text class="arrow-text">›</text>
|
||||||
</view>
|
</view>
|
||||||
</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>
|
</view>
|
||||||
|
|
||||||
<!-- Section header: 系统 -->
|
<!-- Section header: 系统 -->
|
||||||
@@ -154,6 +154,21 @@
|
|||||||
<text class="arrow-text">›</text>
|
<text class="arrow-text">›</text>
|
||||||
</view>
|
</view>
|
||||||
</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>
|
||||||
|
|
||||||
<view style="height: 40rpx" />
|
<view style="height: 40rpx" />
|
||||||
@@ -162,17 +177,24 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
import { getSystemLayout } from '../../utils/system'
|
import { getSystemLayout } from '../../utils/system'
|
||||||
import { useAdminStore } from '../../stores/admin'
|
import { useAdminStore } from '../../stores/admin'
|
||||||
|
import { useUserStore } from '../../stores/user'
|
||||||
import type { AdminStats } from '../../stores/admin'
|
import type { AdminStats } from '../../stores/admin'
|
||||||
|
import { requestAdminBookingSubscriptionCount } from '../../utils/wechat-subscription'
|
||||||
|
import { getErrorMessage } from '../../utils/auth'
|
||||||
|
|
||||||
const navBarHeight = ref('64px')
|
const navBarHeight = ref('64px')
|
||||||
|
|
||||||
const adminStore = useAdminStore()
|
const adminStore = useAdminStore()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const { user } = storeToRefs(userStore)
|
||||||
|
|
||||||
const statsLoading = ref(false)
|
const statsLoading = ref(false)
|
||||||
const stats = ref<AdminStats>({ todayBookings: 0, totalOrders: 0, totalBookings: 0 })
|
const stats = ref<AdminStats>({ todayBookings: 0, totalOrders: 0, totalBookings: 0 })
|
||||||
|
const adminSubscribeLoading = ref(false)
|
||||||
|
|
||||||
function navigate(path: string) {
|
function navigate(path: string) {
|
||||||
uni.navigateTo({ url: path })
|
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(() => {
|
onMounted(() => {
|
||||||
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||||
loadStats()
|
loadStats()
|
||||||
|
userStore.fetchProfile()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -330,7 +375,9 @@ onMounted(() => {
|
|||||||
.icon--members { background: linear-gradient(135deg, $primary-color, $primary-dark); }
|
.icon--members { background: linear-gradient(135deg, $primary-color, $primary-dark); }
|
||||||
.icon--orders { background: linear-gradient(135deg, #7E9EC4, #6E8EB4); }
|
.icon--orders { background: linear-gradient(135deg, #7E9EC4, #6E8EB4); }
|
||||||
.icon--card { background: linear-gradient(135deg, #C48E7E, #B47E6E); }
|
.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--studio { background: linear-gradient(135deg, #9E9E7E, #8E8E6E); }
|
||||||
|
.icon--subscribe { background: linear-gradient(135deg, #5D8C8A, #476D72); }
|
||||||
|
|
||||||
.item-text-group {
|
.item-text-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="page" :style="{ paddingTop: navBarHeight }">
|
<view class="page" :style="{ '--status-bar': statusBarHeight + 'px' }">
|
||||||
<CustomNavBar title="订单管理" show-back />
|
<CustomNavBar title="订单管理" show-back />
|
||||||
|
|
||||||
<!-- Summary stats bar -->
|
<!-- Summary stats bar -->
|
||||||
@@ -44,7 +44,9 @@
|
|||||||
class="list-scroll"
|
class="list-scroll"
|
||||||
:refresher-enabled="true"
|
:refresher-enabled="true"
|
||||||
:refresher-triggered="refreshing"
|
:refresher-triggered="refreshing"
|
||||||
|
:lower-threshold="120"
|
||||||
@refresherrefresh="onRefresh"
|
@refresherrefresh="onRefresh"
|
||||||
|
@scrolltolower="loadMore"
|
||||||
>
|
>
|
||||||
<!-- Loading skeleton -->
|
<!-- Loading skeleton -->
|
||||||
<view v-if="loading && !orders.length" class="order-list">
|
<view v-if="loading && !orders.length" class="order-list">
|
||||||
@@ -106,21 +108,21 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="info-right">
|
<view class="info-right">
|
||||||
<text class="info-label">下单时间</text>
|
<text class="info-label">下单时间</text>
|
||||||
<text class="info-value">{{ formatDate(order.createdAt) }}</text>
|
<text class="info-value">{{ formatDateTime(order.createdAt) }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Paid time if available -->
|
<!-- Paid time if available -->
|
||||||
<view v-if="order.paidAt && order.status === OrderStatus.PAID" class="info-row">
|
<view v-if="order.paidAt && order.status === OrderStatus.PAID" class="info-row">
|
||||||
<text class="info-label">支付时间</text>
|
<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>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Load more / no more -->
|
<!-- 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>
|
<text class="load-more-text">{{ loading ? '加载中...' : '加载更多' }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view v-else-if="orders.length > 0" class="no-more">
|
<view v-else-if="orders.length > 0" class="no-more">
|
||||||
@@ -135,17 +137,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
import { getSystemLayout } from '../../utils/system'
|
|
||||||
import { useAdminStore } from '../../stores/admin'
|
import { useAdminStore } from '../../stores/admin'
|
||||||
import { formatPrice, formatDate } from '../../utils/format'
|
import { formatPrice, formatDateTime } from '../../utils/format'
|
||||||
import { OrderStatus } from '@mp-pilates/shared'
|
import { OrderStatus } from '@mp-pilates/shared'
|
||||||
import type { OrderWithDetails } from '@mp-pilates/shared'
|
import type { OrderWithDetails } from '@mp-pilates/shared'
|
||||||
|
|
||||||
const adminStore = useAdminStore()
|
const adminStore = useAdminStore()
|
||||||
|
|
||||||
const navBarHeight = ref('64px')
|
// 动态计算顶部模块高度
|
||||||
|
const statusBarHeight = ref(0)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
const windowInfo = uni.getWindowInfo()
|
||||||
|
statusBarHeight.value = windowInfo.statusBarHeight ?? 20
|
||||||
})
|
})
|
||||||
|
|
||||||
const filters = [
|
const filters = [
|
||||||
@@ -165,6 +169,21 @@ const totalCount = ref<number | null>(null)
|
|||||||
const paidCount = ref<number | null>(null)
|
const paidCount = ref<number | null>(null)
|
||||||
const pendingCount = 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
|
const LIMIT = 20
|
||||||
|
|
||||||
function statusLabel(s: string) {
|
function statusLabel(s: string) {
|
||||||
@@ -191,23 +210,45 @@ function statusAccentClass(s: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadOrders(reset = false) {
|
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 (loading.value) return
|
||||||
if (reset) page.value = 1
|
if (reset) page.value = 1
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const params: { page: number; limit: number; status?: string } = {
|
const params: { page: number; limit: number; status?: string } = {
|
||||||
page: page.value,
|
page: page.value,
|
||||||
limit: LIMIT,
|
limit: LIMIT,
|
||||||
}
|
}
|
||||||
if (activeFilter.value) params.status = activeFilter.value
|
if (filter) params.status = filter
|
||||||
const result = await adminStore.fetchAdminOrders(params)
|
const result = await adminStore.fetchAdminOrders(params)
|
||||||
if (reset) {
|
|
||||||
orders.value = [...result.data]
|
const newItems = reset ? [...result.items] : [...orders.value, ...result.items]
|
||||||
} else {
|
const newHasMore = newItems.length < result.total
|
||||||
orders.value.push(...result.data)
|
|
||||||
}
|
// 缓存数据
|
||||||
hasMore.value = orders.value.length < result.total
|
setCachedData(filter, {
|
||||||
totalCount.value = result.total
|
items: newItems,
|
||||||
|
total: result.total,
|
||||||
|
page: page.value,
|
||||||
|
hasMore: newHasMore,
|
||||||
|
})
|
||||||
|
|
||||||
|
orders.value = newItems
|
||||||
|
hasMore.value = newHasMore
|
||||||
} catch {
|
} catch {
|
||||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||||
} finally {
|
} finally {
|
||||||
@@ -216,30 +257,79 @@ async function loadOrders(reset = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSummaryCounts() {
|
// 初始加载所有分类的数据
|
||||||
|
async function loadAllFiltersData() {
|
||||||
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const [allResult, paidResult, pendingResult] = await Promise.all([
|
// 并行请求所有分类(第一页数据)
|
||||||
adminStore.fetchAdminOrders({ page: 1, limit: 1 }),
|
const [allResult, paidResult, pendingResult, refundedResult] = await Promise.all([
|
||||||
adminStore.fetchAdminOrders({ page: 1, limit: 1, status: OrderStatus.PAID }),
|
adminStore.fetchAdminOrders({ page: 1, limit: LIMIT }),
|
||||||
adminStore.fetchAdminOrders({ page: 1, limit: 1, status: OrderStatus.PENDING }),
|
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
|
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
|
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
|
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 {
|
} catch {
|
||||||
// non-critical, ignore
|
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
refreshing.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectFilter(value: string) {
|
function selectFilter(value: string) {
|
||||||
activeFilter.value = value
|
activeFilter.value = value
|
||||||
totalCount.value = null
|
// 切换 tab 直接从缓存读取
|
||||||
loadOrders(true)
|
const cached = getCachedData(value)
|
||||||
|
if (cached) {
|
||||||
|
orders.value = [...cached.items]
|
||||||
|
hasMore.value = cached.hasMore
|
||||||
|
page.value = cached.page
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onRefresh() {
|
async function onRefresh() {
|
||||||
refreshing.value = true
|
refreshing.value = true
|
||||||
await Promise.all([loadOrders(true), loadSummaryCounts()])
|
// 下拉刷新重新请求所有分类的数据
|
||||||
|
await loadAllFiltersData()
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadMore() {
|
function loadMore() {
|
||||||
@@ -249,8 +339,7 @@ function loadMore() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadOrders(true)
|
loadAllFiltersData()
|
||||||
loadSummaryCounts()
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -266,13 +355,18 @@ onMounted(() => {
|
|||||||
|
|
||||||
/* ── Stats bar ──────────────────────────────── */
|
/* ── Stats bar ──────────────────────────────── */
|
||||||
.stats-bar {
|
.stats-bar {
|
||||||
|
position: fixed;
|
||||||
|
top: calc(var(--status-bar) + 44px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
height: 96rpx;
|
||||||
background: #FFFFFF;
|
background: #FFFFFF;
|
||||||
padding: 28rpx 0;
|
padding: 0;
|
||||||
margin: 0;
|
|
||||||
border-bottom: 1rpx solid rgba(180, 160, 130, 0.2);
|
border-bottom: 1rpx solid rgba(180, 160, 130, 0.2);
|
||||||
box-shadow: 0 2rpx 12rpx rgba(180, 160, 130, 0.08);
|
box-shadow: 0 2rpx 12rpx rgba(180, 160, 130, 0.08);
|
||||||
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-item {
|
.stat-item {
|
||||||
@@ -309,9 +403,13 @@ onMounted(() => {
|
|||||||
|
|
||||||
/* ── Filter pills ───────────────────────────── */
|
/* ── Filter pills ───────────────────────────── */
|
||||||
.filter-wrap {
|
.filter-wrap {
|
||||||
|
position: fixed;
|
||||||
|
top: calc(var(--status-bar) + 92px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
background: #FAF8F5;
|
background: #FAF8F5;
|
||||||
border-bottom: 1rpx solid rgba(180, 160, 130, 0.15);
|
border-bottom: 1rpx solid rgba(180, 160, 130, 0.15);
|
||||||
flex-shrink: 0;
|
z-index: 99;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-scroll { overflow: hidden; }
|
.filter-scroll { overflow: hidden; }
|
||||||
@@ -364,7 +462,11 @@ onMounted(() => {
|
|||||||
|
|
||||||
/* ── List ───────────────────────────────────── */
|
/* ── List ───────────────────────────────────── */
|
||||||
.list-scroll {
|
.list-scroll {
|
||||||
flex: 1;
|
position: fixed;
|
||||||
|
top: calc(var(--status-bar) + 144px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<view v-else-if="editableSlots.length === 0" class="empty-state">
|
<view v-else-if="editableSlots.length === 0" class="empty-state">
|
||||||
<text class="empty-icon">📭</text>
|
<text class="empty-icon">📭</text>
|
||||||
<text class="empty-text">当日暂无排课</text>
|
<text class="empty-text">当日暂无排课</text>
|
||||||
<text class="empty-sub">无模板匹配,请手动添加时段或先配置排课模板</text>
|
<text class="empty-sub">当日暂无默认时段,请点击下方按钮手动添加</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Slot list -->
|
<!-- Slot list -->
|
||||||
@@ -404,7 +404,7 @@ function slotBadgeClass(slot: EditableSlot): string {
|
|||||||
function slotBadgeText(slot: EditableSlot): string {
|
function slotBadgeText(slot: EditableSlot): string {
|
||||||
if (slot.isNew) return '新增'
|
if (slot.isNew) return '新增'
|
||||||
if (slot.isPublished) return '已发布'
|
if (slot.isPublished) return '已发布'
|
||||||
return '来自模板'
|
return '默认时段'
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Lifecycle ─────────────────────────────────────────────
|
// ── Lifecycle ─────────────────────────────────────────────
|
||||||
|
|||||||
@@ -128,7 +128,7 @@
|
|||||||
</picker>
|
</picker>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<text class="gen-hint">将根据排课模板,自动生成所选日期范围内的时段</text>
|
<text class="gen-hint">将按默认时间表(每天 8:00-22:00,每小时一节)自动生成所选日期范围内的时段</text>
|
||||||
<view class="action-wrap">
|
<view class="action-wrap">
|
||||||
<view class="action-btn" :class="{ 'action-btn--loading': submitting }" @tap="submitGenerate">
|
<view class="action-btn" :class="{ 'action-btn--loading': submitting }" @tap="submitGenerate">
|
||||||
<text class="action-btn-text">{{ submitting ? '生成中...' : '批量生成' }}</text>
|
<text class="action-btn-text">{{ submitting ? '生成中...' : '批量生成' }}</text>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -10,8 +10,8 @@
|
|||||||
|
|
||||||
<!-- ──────────── Date & period filters ──────────── -->
|
<!-- ──────────── Date & period filters ──────────── -->
|
||||||
<view class="filter-header">
|
<view class="filter-header">
|
||||||
<DateSelector v-model="selectedDate" @select="onDateSelect" />
|
<DateSelector v-model="selectedDate" variant="booking" @select="onDateSelect" />
|
||||||
<TimePeriodFilter v-model="selectedPeriod" @change="onPeriodChange" />
|
<TimePeriodFilter v-model="selectedPeriod" variant="booking" @change="onPeriodChange" />
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- ──────────── Slot list ──────────── -->
|
<!-- ──────────── Slot list ──────────── -->
|
||||||
@@ -37,8 +37,10 @@
|
|||||||
|
|
||||||
<!-- Empty state -->
|
<!-- Empty state -->
|
||||||
<view v-else-if="filteredSlots.length === 0" class="empty-wrap">
|
<view v-else-if="filteredSlots.length === 0" class="empty-wrap">
|
||||||
<view class="empty-icon-circle">
|
<view class="empty-illustration">
|
||||||
<text class="empty-icon-text">📅</text>
|
<view class="empty-circle outer" />
|
||||||
|
<view class="empty-circle inner" />
|
||||||
|
<view class="empty-dot" />
|
||||||
</view>
|
</view>
|
||||||
<text class="empty-text">当日暂无可约时段</text>
|
<text class="empty-text">当日暂无可约时段</text>
|
||||||
<text class="empty-sub">请选择其他日期或时段查看</text>
|
<text class="empty-sub">请选择其他日期或时段查看</text>
|
||||||
@@ -59,6 +61,7 @@
|
|||||||
:time-slot="item"
|
:time-slot="item"
|
||||||
@book="onBookTap"
|
@book="onBookTap"
|
||||||
@cancel="onCancelTap"
|
@cancel="onCancelTap"
|
||||||
|
@card-tap="onSlotCardTap"
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -81,9 +84,10 @@
|
|||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||||
import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pilates/shared'
|
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 { useBookingStore } from '../../stores/booking'
|
||||||
import { useUserStore } from '../../stores/user'
|
import { useUserStore } from '../../stores/user'
|
||||||
|
import { getErrorMessage } from '../../utils/auth'
|
||||||
import { formatDate } from '../../utils/format'
|
import { formatDate } from '../../utils/format'
|
||||||
import { getSystemLayout } from '../../utils/system'
|
import { getSystemLayout } from '../../utils/system'
|
||||||
import DateSelector from '../../components/DateSelector.vue'
|
import DateSelector from '../../components/DateSelector.vue'
|
||||||
@@ -148,7 +152,7 @@ updateLayout()
|
|||||||
// ─── Filtered slots ───────────────────────────────────────
|
// ─── Filtered slots ───────────────────────────────────────
|
||||||
const filteredSlots = computed<TimeSlotWithBookingStatus[]>(() => {
|
const filteredSlots = computed<TimeSlotWithBookingStatus[]>(() => {
|
||||||
const slots = bookingStore.slots as TimeSlotWithBookingStatus[]
|
const slots = bookingStore.slots as TimeSlotWithBookingStatus[]
|
||||||
if (!selectedPeriod.value) return [...slots]
|
if (!selectedPeriod.value) return slots
|
||||||
|
|
||||||
const period = TIME_PERIODS[selectedPeriod.value]
|
const period = TIME_PERIODS[selectedPeriod.value]
|
||||||
return slots.filter((slot) => {
|
return slots.filter((slot) => {
|
||||||
@@ -175,11 +179,36 @@ function onDateSelect(date: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onPeriodChange(_period: PeriodKey) {
|
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 ────────────────────────────────────────────
|
// ─── Book flow ────────────────────────────────────────────
|
||||||
async function onBookTap(slot: TimeSlotWithBookingStatus) {
|
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
|
// 1. Ensure logged in
|
||||||
if (!userStore.loggedIn) {
|
if (!userStore.loggedIn) {
|
||||||
uni.showModal({
|
uni.showModal({
|
||||||
@@ -189,12 +218,12 @@ async function onBookTap(slot: TimeSlotWithBookingStatus) {
|
|||||||
success: async (res) => {
|
success: async (res) => {
|
||||||
if (res.confirm) {
|
if (res.confirm) {
|
||||||
try {
|
try {
|
||||||
await userStore.login()
|
const { isNewUser } = await userStore.loginWithSetup()
|
||||||
await userStore.fetchMemberships()
|
if (!isNewUser) {
|
||||||
// Retry booking flow after login
|
onBookTap(slot)
|
||||||
onBookTap(slot)
|
}
|
||||||
} catch {
|
} catch (err: unknown) {
|
||||||
uni.showToast({ title: '登录失败', icon: 'none' })
|
uni.showToast({ title: getErrorMessage(err, '登录失败'), icon: 'none' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -211,7 +240,9 @@ async function onBookTap(slot: TimeSlotWithBookingStatus) {
|
|||||||
cancelText: '取消',
|
cancelText: '取消',
|
||||||
success: (res) => {
|
success: (res) => {
|
||||||
if (res.confirm) {
|
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>
|
<style lang="scss" scoped>
|
||||||
.booking-page {
|
.booking-page {
|
||||||
height: 100vh;
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -290,7 +323,7 @@ onMounted(async () => {
|
|||||||
/* ── Status bar ───────────────────────────────────── */
|
/* ── Status bar ───────────────────────────────────── */
|
||||||
.status-bar {
|
.status-bar {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
background: #fff;
|
background: #fcfaf8;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Page header ──────────────────────────────────── */
|
/* ── Page header ──────────────────────────────────── */
|
||||||
@@ -300,20 +333,21 @@ onMounted(async () => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: #fff;
|
background: #fcfaf8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
font-size: 34rpx;
|
font-size: 34rpx;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
color: #1a1a2e;
|
color: #3a2e2a;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Filter header ────────────────────────────────── */
|
/* ── Filter header ────────────────────────────────── */
|
||||||
.filter-header {
|
.filter-header {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
background: #fff;
|
background: rgba(252, 250, 248, 0.96);
|
||||||
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.04);
|
box-shadow: 0 12rpx 30rpx rgba(120, 91, 79, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Scroll container ──────────────────────────────── */
|
/* ── Scroll container ──────────────────────────────── */
|
||||||
@@ -327,46 +361,46 @@ onMounted(async () => {
|
|||||||
.slot-list {
|
.slot-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 20rpx;
|
padding: 28rpx 0 0;
|
||||||
padding: 24rpx 24rpx 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Date summary ──────────────────────────────────── */
|
/* ── Date summary ──────────────────────────────────── */
|
||||||
.date-summary {
|
.date-summary {
|
||||||
padding: 0 8rpx 4rpx;
|
padding: 0 24rpx 16rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.date-summary-text {
|
.date-summary-text {
|
||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
color: #999;
|
color: #9d8b83;
|
||||||
font-weight: 400;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Loading skeleton ──────────────────────────────── */
|
/* ── Loading skeleton ──────────────────────────────── */
|
||||||
.loading-wrap {
|
.loading-wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 20rpx;
|
gap: 24rpx;
|
||||||
padding: 28rpx 24rpx;
|
padding: 28rpx 24rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeleton-card {
|
.skeleton-card {
|
||||||
height: 140rpx;
|
height: 220rpx;
|
||||||
border-radius: 24rpx;
|
border-radius: 26rpx;
|
||||||
background: #fff;
|
background: rgba(255, 255, 255, 0.88);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 32rpx 28rpx 32rpx 36rpx;
|
padding: 28rpx 48rpx;
|
||||||
gap: 24rpx;
|
gap: 20rpx;
|
||||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.03);
|
margin: 0 24rpx;
|
||||||
|
box-shadow: 0 16rpx 36rpx rgba(120, 91, 79, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeleton-time {
|
.skeleton-time {
|
||||||
width: 80rpx;
|
width: 90rpx;
|
||||||
height: 72rpx;
|
height: 80rpx;
|
||||||
border-radius: 12rpx;
|
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%;
|
background-size: 400% 100%;
|
||||||
animation: shimmer 1.4s infinite;
|
animation: shimmer 1.4s infinite;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -383,7 +417,7 @@ onMounted(async () => {
|
|||||||
width: 60%;
|
width: 60%;
|
||||||
height: 28rpx;
|
height: 28rpx;
|
||||||
border-radius: 8rpx;
|
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%;
|
background-size: 400% 100%;
|
||||||
animation: shimmer 1.4s infinite;
|
animation: shimmer 1.4s infinite;
|
||||||
}
|
}
|
||||||
@@ -392,16 +426,16 @@ onMounted(async () => {
|
|||||||
width: 40%;
|
width: 40%;
|
||||||
height: 20rpx;
|
height: 20rpx;
|
||||||
border-radius: 6rpx;
|
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%;
|
background-size: 400% 100%;
|
||||||
animation: shimmer 1.4s infinite;
|
animation: shimmer 1.4s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeleton-btn {
|
.skeleton-btn {
|
||||||
width: 140rpx;
|
width: 100rpx;
|
||||||
height: 72rpx;
|
height: 60rpx;
|
||||||
border-radius: 36rpx;
|
border-radius: 20rpx;
|
||||||
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%;
|
background-size: 400% 100%;
|
||||||
animation: shimmer 1.4s infinite;
|
animation: shimmer 1.4s infinite;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -413,34 +447,75 @@ onMounted(async () => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 140rpx 40rpx;
|
padding: 120rpx 40rpx 80rpx;
|
||||||
gap: 16rpx;
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-icon-circle {
|
/* Zen-inspired geometric illustration */
|
||||||
width: 140rpx;
|
.empty-illustration {
|
||||||
height: 140rpx;
|
position: relative;
|
||||||
|
width: 200rpx;
|
||||||
|
height: 200rpx;
|
||||||
|
margin-bottom: 56rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-circle {
|
||||||
|
position: absolute;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: $primary-border;
|
top: 50%;
|
||||||
display: flex;
|
left: 50%;
|
||||||
align-items: center;
|
transform: translate(-50%, -50%);
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: 16rpx;
|
&.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 {
|
.empty-dot {
|
||||||
font-size: 56rpx;
|
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 {
|
.empty-text {
|
||||||
font-size: 30rpx;
|
font-size: 32rpx;
|
||||||
color: #666;
|
color: #6f605b;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
letter-spacing: 2rpx;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-sub {
|
.empty-sub {
|
||||||
font-size: 26rpx;
|
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 ─────────────────────────────────── */
|
/* ── Bottom spacer ─────────────────────────────────── */
|
||||||
|
|||||||
@@ -37,25 +37,44 @@
|
|||||||
class="card-row"
|
class="card-row"
|
||||||
@tap="goToDetail(c.id)"
|
@tap="goToDetail(c.id)"
|
||||||
>
|
>
|
||||||
<view class="card-thumb" :class="thumbClass(c)">
|
<!-- Card Cover — image if available, gradient fallback -->
|
||||||
<view class="thumb-fallback">
|
<view class="card-cover" :class="c.coverUrl ? '' : getCardCoverClass(c.type)">
|
||||||
<text class="thumb-name">{{ truncate(c.name, 8) }}</text>
|
<image
|
||||||
<text class="thumb-price">¥{{ formatPrice(c.price) }}</text>
|
v-if="c.coverUrl"
|
||||||
</view>
|
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>
|
</view>
|
||||||
|
|
||||||
|
<!-- Card info — aligns with card-cover height -->
|
||||||
<view class="card-info">
|
<view class="card-info">
|
||||||
<text class="card-name">{{ c.name }}</text>
|
<view class="info-top">
|
||||||
<text class="card-validity">有效期:{{ c.durationDays }} 天</text>
|
<text class="card-name">{{ c.name }}</text>
|
||||||
<view class="price-row">
|
<text class="card-validity">有效期 {{ c.durationDays }} 天</text>
|
||||||
<text class="price-current">¥{{ formatPrice(c.price) }}</text>
|
</view>
|
||||||
<text
|
<view class="info-bottom">
|
||||||
v-if="c.originalPrice && c.originalPrice > c.price"
|
<view v-if="c.totalTimes" class="card-times">
|
||||||
class="price-original"
|
<text class="card-times-value">{{ c.totalTimes }}</text>
|
||||||
>
|
<text class="card-times-unit">课时</text>
|
||||||
原价:¥{{ formatPrice(c.originalPrice) }}
|
</view>
|
||||||
</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>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<text class="card-arrow">›</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view v-else class="empty-state">
|
<view v-else class="empty-state">
|
||||||
@@ -66,23 +85,32 @@
|
|||||||
<!-- Card content (single card mode) -->
|
<!-- Card content (single card mode) -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- Hero section -->
|
<!-- Hero section -->
|
||||||
<view class="card-hero" :class="heroClass">
|
<view class="card-hero" :class="cardData.coverUrl ? 'hero--custom' : heroClass">
|
||||||
<!-- Decorative circles -->
|
<!-- Cover image background -->
|
||||||
<view class="hero-deco hero-deco--1" />
|
<image
|
||||||
<view class="hero-deco hero-deco--2" />
|
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">
|
<view class="hero-badge">
|
||||||
<text class="hero-badge-text">{{ typeLabel }}</text>
|
<text class="hero-badge-text">{{ typeLabel }}</text>
|
||||||
</view>
|
</view>
|
||||||
<text class="hero-name">{{ card.name }}</text>
|
<text class="hero-name">{{ cardData.name }}</text>
|
||||||
<view class="hero-price-row">
|
<view class="hero-price-row">
|
||||||
<text class="hero-currency">¥</text>
|
<text class="hero-currency">¥</text>
|
||||||
<text class="hero-price">{{ formatPrice(card.price) }}</text>
|
<text class="hero-price">{{ formatPrice(cardData.price) }}</text>
|
||||||
<text
|
<text
|
||||||
v-if="card.originalPrice && card.originalPrice > card.price"
|
v-if="cardData.originalPrice && cardData.originalPrice > cardData.price"
|
||||||
class="hero-original"
|
class="hero-original"
|
||||||
>
|
>
|
||||||
¥{{ formatPrice(card.originalPrice) }}
|
¥{{ formatPrice(cardData.originalPrice) }}
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -92,28 +120,28 @@
|
|||||||
<!-- Key info grid -->
|
<!-- Key info grid -->
|
||||||
<view class="info-card">
|
<view class="info-card">
|
||||||
<view class="info-grid">
|
<view class="info-grid">
|
||||||
<view class="info-cell" v-if="card.totalTimes">
|
<view class="info-cell" v-if="cardData.totalTimes">
|
||||||
<text class="cell-value">{{ card.totalTimes }}</text>
|
<text class="cell-value">{{ cardData.totalTimes }}</text>
|
||||||
<text class="cell-label">课时次数</text>
|
<text class="cell-label">课时次数</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="info-cell">
|
<view class="info-cell">
|
||||||
<text class="cell-value">{{ card.durationDays }}</text>
|
<text class="cell-value">{{ cardData.durationDays }}</text>
|
||||||
<text class="cell-label">有效天数</text>
|
<text class="cell-label">有效天数</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="info-cell">
|
<view class="info-cell">
|
||||||
<text class="cell-value">{{ unitPrice }}</text>
|
<text class="cell-value">{{ unitPrice }}</text>
|
||||||
<text class="cell-label">{{ card.totalTimes ? '每次单价' : '按天均价' }}</text>
|
<text class="cell-label">{{ cardData.totalTimes ? '每次单价' : '按天均价' }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<view v-if="card.description" class="desc-card">
|
<view v-if="cardData.description" class="desc-card">
|
||||||
<view class="section-header">
|
<view class="section-header">
|
||||||
<view class="section-dot" />
|
<view class="section-dot" />
|
||||||
<text class="section-title">课程说明</text>
|
<text class="section-title">课程说明</text>
|
||||||
</view>
|
</view>
|
||||||
<text class="desc-content">{{ card.description }}</text>
|
<text class="desc-content">{{ cardData.description }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Features list -->
|
<!-- Features list -->
|
||||||
@@ -124,13 +152,13 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="feature-item">
|
<view class="feature-item">
|
||||||
<text class="feature-dot">•</text>
|
<text class="feature-dot">•</text>
|
||||||
<text class="feature-text">购买后立即生效,有效期 {{ card.durationDays }} 天</text>
|
<text class="feature-text">购买后立即生效,有效期 {{ cardData.durationDays }} 天</text>
|
||||||
</view>
|
</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-dot">•</text>
|
||||||
<text class="feature-text">共 {{ card.totalTimes }} 次课时,可灵活安排上课时间</text>
|
<text class="feature-text">共 {{ cardData.totalTimes }} 次课时,可灵活安排上课时间</text>
|
||||||
</view>
|
</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-dot">•</text>
|
||||||
<text class="feature-text">有效期内可无限次预约课程</text>
|
<text class="feature-text">有效期内可无限次预约课程</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -153,7 +181,7 @@
|
|||||||
<view class="bottom-bar">
|
<view class="bottom-bar">
|
||||||
<view class="price-summary">
|
<view class="price-summary">
|
||||||
<text class="summary-label">实付金额</text>
|
<text class="summary-label">实付金额</text>
|
||||||
<text class="summary-price">¥{{ formatPrice(card.price) }}</text>
|
<text class="summary-price">¥{{ formatPrice(cardData.price) }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view
|
<view
|
||||||
class="buy-btn"
|
class="buy-btn"
|
||||||
@@ -171,10 +199,12 @@
|
|||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import type { CardType, CreateOrderResponse } from '@mp-pilates/shared'
|
import type { CardType, CreateOrderResponse } from '@mp-pilates/shared'
|
||||||
import { CardTypeCategory } from '@mp-pilates/shared'
|
import { CardTypeCategory } from '@mp-pilates/shared'
|
||||||
|
import { getErrorMessage } from '../../utils/auth'
|
||||||
import { get, post } from '../../utils/request'
|
import { get, post } from '../../utils/request'
|
||||||
import { formatPrice } from '../../utils/format'
|
import { formatPrice, getCardTypeLabel, getCardCoverClass } from '../../utils/format'
|
||||||
import { getSystemLayout } from '../../utils/system'
|
import { getSystemLayout } from '../../utils/system'
|
||||||
import { useUserStore } from '../../stores/user'
|
import { useUserStore } from '../../stores/user'
|
||||||
|
import { requestOrderPaidSubscriptionMessage } from '../../utils/wechat-subscription'
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
@@ -221,6 +251,8 @@ const unitPrice = computed(() => {
|
|||||||
return `¥${(pricePerDay / 100).toFixed(0)}`
|
return `¥${(pricePerDay / 100).toFixed(0)}`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const cardData = computed<CardType>(() => card.value as CardType)
|
||||||
|
|
||||||
// ─── Data loading ─────────────────────────────────────────
|
// ─── Data loading ─────────────────────────────────────────
|
||||||
async function loadCard() {
|
async function loadCard() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -257,16 +289,6 @@ function goToDetail(id: string) {
|
|||||||
uni.navigateTo({ url: `/pages/card/detail?id=${id}` })
|
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 ─────────────────────────────────────────────
|
// ─── Buy flow ─────────────────────────────────────────────
|
||||||
async function handleBuy() {
|
async function handleBuy() {
|
||||||
if (buying.value || !card.value) return
|
if (buying.value || !card.value) return
|
||||||
@@ -280,10 +302,12 @@ async function handleBuy() {
|
|||||||
success: async (res) => {
|
success: async (res) => {
|
||||||
if (res.confirm) {
|
if (res.confirm) {
|
||||||
try {
|
try {
|
||||||
await userStore.login()
|
const { isNewUser } = await userStore.loginWithSetup()
|
||||||
handleBuy()
|
if (!isNewUser) {
|
||||||
} catch {
|
handleBuy()
|
||||||
uni.showToast({ title: '登录失败', icon: 'none' })
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
uni.showToast({ title: getErrorMessage(err, '登录失败'), icon: 'none' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -308,8 +332,10 @@ async function doPurchase() {
|
|||||||
uni.showLoading({ title: '创建订单...' })
|
uni.showLoading({ title: '创建订单...' })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const inviterId = uni.getStorageSync('invite_inviter_id') as string
|
||||||
const result = await post<CreateOrderResponse>('/payment/create-order', {
|
const result = await post<CreateOrderResponse>('/payment/create-order', {
|
||||||
cardTypeId: card.value.id,
|
cardTypeId: card.value.id,
|
||||||
|
inviterId: isTrial.value && inviterId ? inviterId : undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
uni.hideLoading()
|
uni.hideLoading()
|
||||||
@@ -329,6 +355,7 @@ async function doPurchase() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Payment succeeded — refresh memberships then navigate
|
// Payment succeeded — refresh memberships then navigate
|
||||||
|
await requestOrderPaidSubscriptionMessage().catch(() => undefined)
|
||||||
uni.showToast({ title: '购买成功!', icon: 'success' })
|
uni.showToast({ title: '购买成功!', icon: 'success' })
|
||||||
await userStore.fetchMemberships()
|
await userStore.fetchMemberships()
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -438,23 +465,35 @@ onMounted(() => {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
&.hero--times {
|
&.hero--times {
|
||||||
background: linear-gradient(135deg, #1a1a2e 0%, #2d2d5e 100%);
|
background: linear-gradient(135deg, #E8D5C4 0%, #D4BFA8 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.hero--duration {
|
&.hero--duration {
|
||||||
background: linear-gradient(135deg, #6c3483 0%, #9b59b6 100%);
|
background: linear-gradient(135deg, #D8C8DC 0%, #C4AECB 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.hero--trial {
|
&.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 */
|
/* Decorative background circles */
|
||||||
.hero-deco {
|
.hero-deco {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.35);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
|
||||||
&--1 {
|
&--1 {
|
||||||
@@ -476,14 +515,14 @@ onMounted(() => {
|
|||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
padding: 8rpx 22rpx;
|
padding: 8rpx 22rpx;
|
||||||
border-radius: 20rpx;
|
border-radius: 20rpx;
|
||||||
background: rgba(255, 255, 255, 0.18);
|
background: rgba(74, 64, 53, 0.1);
|
||||||
border: 1rpx solid rgba(255, 255, 255, 0.3);
|
border: 1rpx solid rgba(74, 64, 53, 0.15);
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-badge-text {
|
.hero-badge-text {
|
||||||
font-size: 22rpx;
|
font-size: 22rpx;
|
||||||
color: #fff;
|
color: $brand-color;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 1rpx;
|
letter-spacing: 1rpx;
|
||||||
}
|
}
|
||||||
@@ -491,7 +530,7 @@ onMounted(() => {
|
|||||||
.hero-name {
|
.hero-name {
|
||||||
font-size: 48rpx;
|
font-size: 48rpx;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: #fff;
|
color: $brand-color;
|
||||||
letter-spacing: 1rpx;
|
letter-spacing: 1rpx;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
@@ -506,20 +545,20 @@ onMounted(() => {
|
|||||||
.hero-currency {
|
.hero-currency {
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: rgba(255, 255, 255, 0.85);
|
color: rgba(74, 64, 53, 0.7);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-price {
|
.hero-price {
|
||||||
font-size: 64rpx;
|
font-size: 64rpx;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: #fff;
|
color: $brand-color;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-original {
|
.hero-original {
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
color: rgba(255, 255, 255, 0.5);
|
color: rgba(74, 64, 53, 0.4);
|
||||||
text-decoration: line-through;
|
text-decoration: line-through;
|
||||||
margin-left: 8rpx;
|
margin-left: 8rpx;
|
||||||
}
|
}
|
||||||
@@ -552,7 +591,7 @@ onMounted(() => {
|
|||||||
.section-title {
|
.section-title {
|
||||||
font-size: 30rpx;
|
font-size: 30rpx;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #1a1a1a;
|
color: $text-primary;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Info grid card ──────────────────────────────────── */
|
/* ── Info grid card ──────────────────────────────────── */
|
||||||
@@ -710,101 +749,150 @@ onMounted(() => {
|
|||||||
.card-row {
|
.card-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 24rpx;
|
gap: 20rpx;
|
||||||
padding: 24rpx;
|
padding: 20rpx;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 20rpx;
|
border-radius: 20rpx;
|
||||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
|
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-thumb {
|
/* ══════════════════════════════════════════════════════════
|
||||||
|
CARD COVER — Clean minimal design
|
||||||
|
══════════════════════════════════════════════════════════ */
|
||||||
|
.card-cover {
|
||||||
width: 200rpx;
|
width: 200rpx;
|
||||||
height: 140rpx;
|
height: 130rpx;
|
||||||
border-radius: 12rpx;
|
border-radius: 16rpx;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thumb-fallback {
|
.card-cover-img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8rpx;
|
|
||||||
padding: 12rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.thumb--times .thumb-fallback {
|
.cover-deco {
|
||||||
background: linear-gradient(135deg, #3a3a3a, #555);
|
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 {
|
.cover--times {
|
||||||
background: linear-gradient(135deg, #6c3483, #9b59b6);
|
background: linear-gradient(135deg, #E8D5C4 0%, #D4BFA8 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.thumb--trial .thumb-fallback {
|
.cover--duration {
|
||||||
background: linear-gradient(135deg, #5a7a8a, $primary-dark);
|
background: linear-gradient(135deg, #D8C8DC 0%, #C4AECB 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.thumb-name {
|
.cover--trial {
|
||||||
font-size: 22rpx;
|
background: linear-gradient(135deg, #C8D8D2 0%, #A9C4BC 100%);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Card info — matches card-cover height ── */
|
||||||
.card-info {
|
.card-info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
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 {
|
.card-name {
|
||||||
display: block;
|
|
||||||
font-size: 30rpx;
|
font-size: 30rpx;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
color: #222;
|
color: $text-primary;
|
||||||
margin-bottom: 8rpx;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
letter-spacing: 0.5rpx;
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-validity {
|
.card-validity {
|
||||||
display: block;
|
font-size: 23rpx;
|
||||||
font-size: 24rpx;
|
color: $text-secondary;
|
||||||
color: #999;
|
line-height: 1.2;
|
||||||
margin-bottom: 12rpx;
|
}
|
||||||
|
|
||||||
|
.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 {
|
.price-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 8rpx;
|
gap: 6rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.price-current {
|
.price-current {
|
||||||
font-size: 40rpx;
|
font-size: 32rpx;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: #e53935;
|
color: $brand-color;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.price-original {
|
.price-original {
|
||||||
font-size: 22rpx;
|
font-size: 20rpx;
|
||||||
color: #bbb;
|
color: $text-hint;
|
||||||
text-decoration: line-through;
|
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 ─────────────────────────────────────── */
|
||||||
.empty-state {
|
.empty-state {
|
||||||
padding: 160rpx 40rpx;
|
padding: 160rpx 40rpx;
|
||||||
|
|||||||
840
packages/app/src/pages/flash-sale/detail.vue
Normal file
840
packages/app/src/pages/flash-sale/detail.vue
Normal 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>
|
||||||
@@ -1,58 +1,73 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="home-page" :style="pageStyle">
|
<view class="home-page">
|
||||||
<!-- ──────────── Custom nav bar ──────────── -->
|
<!-- ① Brand Banner — fixed background layer -->
|
||||||
<CustomNavBar title="场馆首页" />
|
<view class="banner-fixed">
|
||||||
|
<BrandBanner :studio-info="studioStore.studioInfo" />
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- Pull-to-refresh wrapper -->
|
<!-- Pull-to-refresh wrapper — scrollable foreground -->
|
||||||
<scroll-view
|
<scroll-view
|
||||||
class="page-scroll"
|
class="page-scroll"
|
||||||
scroll-y
|
scroll-y
|
||||||
|
:scroll-top="scrollTop"
|
||||||
|
:scroll-with-animation="true"
|
||||||
:refresher-enabled="true"
|
:refresher-enabled="true"
|
||||||
:refresher-triggered="refreshing"
|
:refresher-triggered="refreshing"
|
||||||
@refresherrefresh="handleRefresh"
|
@refresherrefresh="handleRefresh"
|
||||||
@refresherrestore="refreshing = false"
|
@refresherrestore="refreshing = false"
|
||||||
>
|
>
|
||||||
<!-- ① Brand Banner (hero with bg image + centered logo) -->
|
<!-- Transparent spacer to reveal Banner behind -->
|
||||||
<BrandBanner :studio-info="studioStore.studioInfo" />
|
<view class="banner-spacer" />
|
||||||
|
|
||||||
<!-- ② Studio Info (photo strip + address/phone) -->
|
<!-- Floating card with rounded top corners -->
|
||||||
<StudioInfo :studio-info="studioStore.studioInfo" />
|
<view class="floating-card">
|
||||||
|
<!-- Drag indicator -->
|
||||||
|
<view class="card-handle">
|
||||||
|
<view class="card-handle-bar" />
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- Divider -->
|
<!-- ② Studio Info (photo strip + address/phone) -->
|
||||||
<view class="section-divider" />
|
<StudioInfo :studio-info="studioStore.studioInfo" />
|
||||||
|
|
||||||
<!-- ③ Quick Entry (login / trial / book / renew) -->
|
<!-- ③ Quick Entry (login / trial / book / renew) -->
|
||||||
<QuickEntry @scroll-to-card-shop="scrollToCardShop" />
|
<QuickEntry @scroll-to-card-shop="scrollToCardShop" />
|
||||||
|
|
||||||
<!-- ④ Upcoming Bookings -->
|
<!-- ④ Upcoming Bookings -->
|
||||||
<UpcomingBooking />
|
<UpcomingBooking />
|
||||||
|
|
||||||
<!-- ⑤ Card Shop (vertical list) -->
|
<!-- ④.5 Flash Sale Section -->
|
||||||
<view :id="cardShopAnchorId">
|
<FlashSaleSection ref="flashSaleRef" />
|
||||||
<CardShop ref="cardShopRef" />
|
|
||||||
|
<!-- ⑤ 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>
|
</view>
|
||||||
|
|
||||||
<!-- Bottom padding for tab bar -->
|
|
||||||
<view class="bottom-padding" />
|
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, nextTick, onUnmounted } from 'vue'
|
||||||
import { onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
import { onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||||
|
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
|
||||||
import BrandBanner from '../../components/BrandBanner.vue'
|
import BrandBanner from '../../components/BrandBanner.vue'
|
||||||
import StudioInfo from '../../components/StudioInfo.vue'
|
import StudioInfo from '../../components/StudioInfo.vue'
|
||||||
import QuickEntry from '../../components/QuickEntry.vue'
|
import QuickEntry from '../../components/QuickEntry.vue'
|
||||||
import UpcomingBooking from '../../components/UpcomingBooking.vue'
|
import UpcomingBooking from '../../components/UpcomingBooking.vue'
|
||||||
|
import FlashSaleSection from '../../components/FlashSaleSection.vue'
|
||||||
import CardShop from '../../components/CardShop.vue'
|
import CardShop from '../../components/CardShop.vue'
|
||||||
|
import AboutSection from '../../components/AboutSection.vue'
|
||||||
|
|
||||||
import { useUserStore } from '../../stores/user'
|
import { useUserStore } from '../../stores/user'
|
||||||
import { useStudioStore } from '../../stores/studio'
|
import { useStudioStore } from '../../stores/studio'
|
||||||
import { useBookingStore } from '../../stores/booking'
|
import { useBookingStore } from '../../stores/booking'
|
||||||
import { getSystemLayout } from '../../utils/system'
|
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const studioStore = useStudioStore()
|
const studioStore = useStudioStore()
|
||||||
@@ -75,28 +90,32 @@ onShareTimeline(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// ─── Layout ───────────────────────────────────────────────
|
// ─── 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 refreshing = ref(false)
|
||||||
const cardShopRef = ref<InstanceType<typeof CardShop> | null>(null)
|
const cardShopRef = ref<InstanceType<typeof CardShop> | null>(null)
|
||||||
|
const flashSaleRef = ref<InstanceType<typeof FlashSaleSection> | null>(null)
|
||||||
const cardShopAnchorId = 'card-shop-anchor'
|
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
|
// Refresh all data on every show
|
||||||
onShow(async () => {
|
onShow(async () => {
|
||||||
await refreshData()
|
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() {
|
async function refreshData() {
|
||||||
@@ -104,6 +123,7 @@ async function refreshData() {
|
|||||||
|
|
||||||
if (userStore.loggedIn) {
|
if (userStore.loggedIn) {
|
||||||
tasks.push(
|
tasks.push(
|
||||||
|
userStore.fetchProfile(),
|
||||||
userStore.fetchMemberships(),
|
userStore.fetchMemberships(),
|
||||||
bookingStore.fetchUpcomingBookings(),
|
bookingStore.fetchUpcomingBookings(),
|
||||||
)
|
)
|
||||||
@@ -111,8 +131,9 @@ async function refreshData() {
|
|||||||
|
|
||||||
await Promise.allSettled(tasks)
|
await Promise.allSettled(tasks)
|
||||||
|
|
||||||
// Also refresh card shop
|
// Also refresh card shop and flash sales
|
||||||
cardShopRef.value?.fetchCardTypes()
|
cardShopRef.value?.fetchCardTypes()
|
||||||
|
flashSaleRef.value?.fetchFlashSales()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRefresh() {
|
async function handleRefresh() {
|
||||||
@@ -125,27 +146,74 @@ async function handleRefresh() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function scrollToCardShop() {
|
function scrollToCardShop() {
|
||||||
uni.pageScrollTo({
|
// Reset first so setting the same value still triggers scroll
|
||||||
selector: `#${cardShopAnchorId}`,
|
scrollTop.value = 0
|
||||||
duration: 300,
|
nextTick(() => {
|
||||||
|
uni.createSelectorQuery()
|
||||||
|
.select(`#${cardShopAnchorId}`)
|
||||||
|
.boundingClientRect()
|
||||||
|
.selectViewport()
|
||||||
|
.scrollOffset((res) => {
|
||||||
|
if (res) {
|
||||||
|
scrollTop.value = (res as UniApp.NodeInfo).scrollTop ?? 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.exec()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.home-page {
|
.home-page {
|
||||||
min-height: 100vh;
|
position: relative;
|
||||||
|
height: 100vh;
|
||||||
background: #FAF8F5;
|
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 {
|
.page-scroll {
|
||||||
height: calc(100vh - var(--nav-bar-height));
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
flex: 1;
|
||||||
|
height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-divider {
|
/* Transparent spacer lets banner peek through */
|
||||||
height: 16rpx;
|
.banner-spacer {
|
||||||
|
height: 420rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Floating card that overlaps the banner */
|
||||||
|
.floating-card {
|
||||||
|
position: relative;
|
||||||
background: #FAF8F5;
|
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 {
|
.bottom-padding {
|
||||||
|
|||||||
@@ -41,7 +41,9 @@
|
|||||||
<!-- Empty -->
|
<!-- Empty -->
|
||||||
<view v-else-if="upcomingBookings.length === 0" class="empty-wrap">
|
<view v-else-if="upcomingBookings.length === 0" class="empty-wrap">
|
||||||
<view class="empty-illustration">
|
<view class="empty-illustration">
|
||||||
<text class="empty-icon">🧘</text>
|
<view class="empty-circle outer" />
|
||||||
|
<view class="empty-circle inner" />
|
||||||
|
<view class="empty-dot" />
|
||||||
</view>
|
</view>
|
||||||
<text class="empty-title">暂无即将上课的预约</text>
|
<text class="empty-title">暂无即将上课的预约</text>
|
||||||
<text class="empty-sub">开始预约你的普拉提课程吧</text>
|
<text class="empty-sub">开始预约你的普拉提课程吧</text>
|
||||||
@@ -117,7 +119,9 @@
|
|||||||
<!-- Empty -->
|
<!-- Empty -->
|
||||||
<view v-else-if="historyBookings.length === 0" class="empty-wrap">
|
<view v-else-if="historyBookings.length === 0" class="empty-wrap">
|
||||||
<view class="empty-illustration">
|
<view class="empty-illustration">
|
||||||
<text class="empty-icon">📋</text>
|
<view class="empty-circle outer" />
|
||||||
|
<view class="empty-circle inner" />
|
||||||
|
<view class="empty-dot" />
|
||||||
</view>
|
</view>
|
||||||
<text class="empty-title">暂无历史记录</text>
|
<text class="empty-title">暂无历史记录</text>
|
||||||
<text class="empty-sub">已完成或取消的课程将显示在这里</text>
|
<text class="empty-sub">已完成或取消的课程将显示在这里</text>
|
||||||
@@ -481,33 +485,73 @@ onMounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 120rpx 40rpx;
|
padding: 120rpx 40rpx;
|
||||||
gap: 16rpx;
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-illustration {
|
.empty-illustration {
|
||||||
width: 160rpx;
|
position: relative;
|
||||||
height: 160rpx;
|
width: 200rpx;
|
||||||
border-radius: 80rpx;
|
height: 200rpx;
|
||||||
background: #faf6f1;
|
margin-bottom: 56rpx;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: 16rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-icon {
|
.empty-circle {
|
||||||
font-size: 72rpx;
|
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 {
|
.empty-title {
|
||||||
font-size: 32rpx;
|
font-size: 32rpx;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #333;
|
color: $primary-dark;
|
||||||
|
letter-spacing: 2rpx;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-sub {
|
.empty-sub {
|
||||||
font-size: 26rpx;
|
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 {
|
.empty-btn {
|
||||||
|
|||||||
@@ -4,23 +4,17 @@
|
|||||||
<CustomNavBar title="我的" transparent />
|
<CustomNavBar title="我的" transparent />
|
||||||
|
|
||||||
<!-- User card -->
|
<!-- User card -->
|
||||||
<UserCard
|
<UserCard :logged-in="loggedIn" :has-profile="hasProfile" :user="user" :stats="stats" :memberships="memberships"
|
||||||
:logged-in="loggedIn"
|
:loading="loginLoading" :nav-bar-height="navBarHeight" @login="handleLogin" />
|
||||||
:has-profile="hasProfile"
|
|
||||||
:user="user"
|
|
||||||
:stats="stats"
|
|
||||||
:memberships="memberships"
|
|
||||||
:loading="loginLoading"
|
|
||||||
:nav-bar-height="navBarHeight"
|
|
||||||
@login="handleLogin"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Menu section: always visible -->
|
<!-- Menu section: always visible -->
|
||||||
<ProfileMenu
|
<ProfileMenu
|
||||||
:is-admin="isAdmin"
|
:is-admin="isAdmin"
|
||||||
:require-auth="loggedIn"
|
:require-auth="loggedIn"
|
||||||
|
:active-membership-count="activeMembershipCount"
|
||||||
|
:upcoming-booking-count="upcomingBookingCount"
|
||||||
|
:invite-share-eligible="!!user?.inviteShareEligible"
|
||||||
@clear-cache="handleClearCache"
|
@clear-cache="handleClearCache"
|
||||||
@about="handleAbout"
|
|
||||||
@require-login="handleLogin"
|
@require-login="handleLogin"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -32,21 +26,33 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
import { onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useUserStore } from '../../stores/user'
|
import { useUserStore } from '../../stores/user'
|
||||||
|
import { useBookingStore } from '../../stores/booking'
|
||||||
import { getSystemLayout } from '../../utils/system'
|
import { getSystemLayout } from '../../utils/system'
|
||||||
|
import { getErrorMessage } from '../../utils/auth'
|
||||||
import UserCard from '../../components/UserCard.vue'
|
import UserCard from '../../components/UserCard.vue'
|
||||||
import ProfileMenu from '../../components/ProfileMenu.vue'
|
import ProfileMenu from '../../components/ProfileMenu.vue'
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const bookingStore = useBookingStore()
|
||||||
const { loggedIn, hasProfile, user, stats, memberships, isAdmin } = storeToRefs(userStore)
|
const { loggedIn, hasProfile, user, stats, memberships, isAdmin } = storeToRefs(userStore)
|
||||||
|
const { upcomingBookings } = storeToRefs(bookingStore)
|
||||||
|
|
||||||
const loginLoading = ref(false)
|
const loginLoading = ref(false)
|
||||||
const navBarHeight = ref(64)
|
const navBarHeight = ref(64)
|
||||||
|
|
||||||
|
const activeMembershipCount = computed(
|
||||||
|
() => user.value?.activeMembershipCount ?? userStore.activeMemberships.length,
|
||||||
|
)
|
||||||
|
|
||||||
|
const upcomingBookingCount = computed(
|
||||||
|
() => (loggedIn.value ? upcomingBookings.value.length : 0),
|
||||||
|
)
|
||||||
|
|
||||||
// ─── 微信分享 ───────────────────────────────────────────────
|
// ─── 微信分享 ───────────────────────────────────────────────
|
||||||
onShareAppMessage(() => {
|
onShareAppMessage(() => {
|
||||||
return {
|
return {
|
||||||
@@ -73,6 +79,7 @@ onShow(async () => {
|
|||||||
userStore.fetchProfile(),
|
userStore.fetchProfile(),
|
||||||
userStore.fetchStats(),
|
userStore.fetchStats(),
|
||||||
userStore.fetchMemberships(),
|
userStore.fetchMemberships(),
|
||||||
|
bookingStore.fetchUpcomingBookings(),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -81,14 +88,15 @@ async function handleLogin() {
|
|||||||
if (loginLoading.value) return
|
if (loginLoading.value) return
|
||||||
loginLoading.value = true
|
loginLoading.value = true
|
||||||
try {
|
try {
|
||||||
await userStore.login()
|
const { isNewUser } = await userStore.loginWithSetup()
|
||||||
await Promise.all([
|
if (!isNewUser) {
|
||||||
userStore.fetchProfile(),
|
await Promise.all([
|
||||||
userStore.fetchStats(),
|
userStore.fetchStats(),
|
||||||
userStore.fetchMemberships(),
|
bookingStore.fetchUpcomingBookings(),
|
||||||
])
|
])
|
||||||
} catch {
|
}
|
||||||
uni.showToast({ title: '登录失败,请重试', icon: 'none' })
|
} catch (err: unknown) {
|
||||||
|
uni.showToast({ title: getErrorMessage(err, '登录失败,请重试'), icon: 'none' })
|
||||||
} finally {
|
} finally {
|
||||||
loginLoading.value = false
|
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>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.profile-page {
|
.profile-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: $bg-page;
|
|
||||||
|
|
||||||
&__logout-wrap {
|
&__logout-wrap {
|
||||||
margin: $spacing-xl $spacing-lg $spacing-xl;
|
margin: $spacing-xl $spacing-lg $spacing-xl;
|
||||||
|
|||||||
@@ -1,8 +1,22 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="info-page" :style="{ paddingTop: navBarHeight }">
|
<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 -->
|
<!-- Avatar section -->
|
||||||
<view class="avatar-section">
|
<view class="avatar-section" :class="{ 'avatar-section--welcome': isFromLogin }">
|
||||||
<button class="avatar-btn" open-type="chooseAvatar" @chooseavatar="handleChooseAvatar">
|
<button class="avatar-btn" open-type="chooseAvatar" @chooseavatar="handleChooseAvatar">
|
||||||
<view class="avatar-wrap">
|
<view class="avatar-wrap">
|
||||||
<image
|
<image
|
||||||
@@ -14,10 +28,14 @@
|
|||||||
<view v-else class="avatar-placeholder">
|
<view v-else class="avatar-placeholder">
|
||||||
<text class="avatar-placeholder-text">{{ nicknameInitial }}</text>
|
<text class="avatar-placeholder-text">{{ nicknameInitial }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
<!-- Upload hint overlay -->
|
||||||
|
<view class="avatar-overlay">
|
||||||
|
<text class="avatar-overlay-text">点击更换</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</button>
|
</button>
|
||||||
<text class="avatar-name">{{ form.nickname || '未设置昵称' }}</text>
|
<text class="avatar-name">{{ form.nickname || '未设置昵称' }}</text>
|
||||||
<text class="avatar-hint">微信头像</text>
|
<text class="avatar-hint">点击头像选择微信头像</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Form fields -->
|
<!-- Form fields -->
|
||||||
@@ -37,8 +55,8 @@
|
|||||||
<text class="form-arrow">›</text>
|
<text class="form-arrow">›</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Phone -->
|
<!-- Phone (hide in first-login mode) -->
|
||||||
<view class="form-row form-row--last">
|
<view v-if="!isFromLogin" class="form-row form-row--last">
|
||||||
<text class="form-label">手机号</text>
|
<text class="form-label">手机号</text>
|
||||||
|
|
||||||
<!-- Phone set: display masked -->
|
<!-- Phone set: display masked -->
|
||||||
@@ -56,8 +74,8 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Read-only info card -->
|
<!-- Read-only info card (hide in first-login mode) -->
|
||||||
<view class="info-card">
|
<view v-if="!isFromLogin" class="info-card">
|
||||||
<view class="info-row">
|
<view class="info-row">
|
||||||
<text class="info-label">注册时间</text>
|
<text class="info-label">注册时间</text>
|
||||||
<text class="info-value">{{ joinDateDisplay }}</text>
|
<text class="info-value">{{ joinDateDisplay }}</text>
|
||||||
@@ -72,24 +90,40 @@
|
|||||||
<view class="save-wrap">
|
<view class="save-wrap">
|
||||||
<view
|
<view
|
||||||
class="save-btn"
|
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"
|
@tap="handleSave"
|
||||||
>
|
>
|
||||||
<text class="save-btn-text">{{ saving ? '保存中...' : '保存修改' }}</text>
|
<text class="save-btn-text">
|
||||||
|
{{ saving ? '保存中...' : isFromLogin ? '保存并进入' : '保存修改' }}
|
||||||
|
</text>
|
||||||
</view>
|
</view>
|
||||||
|
<text v-if="isFromLogin" class="skip-text" @tap="handleSkip">稍后再说</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
import { useUserStore } from '../../stores/user'
|
import { useUserStore } from '../../stores/user'
|
||||||
import { wxBindPhone } from '../../utils/auth'
|
import { wxBindPhone } from '../../utils/auth'
|
||||||
import { getSystemLayout } from '../../utils/system'
|
import { getSystemLayout } from '../../utils/system'
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
|
||||||
|
const TOAST_DISPLAY_MS = 1200
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
// ─── Route params ────────────────────────────────────────
|
||||||
|
const isFromLogin = ref(false)
|
||||||
|
|
||||||
|
onLoad((query) => {
|
||||||
|
isFromLogin.value = query?.from === 'login'
|
||||||
|
})
|
||||||
|
|
||||||
// ─── Nav bar height ──────────────────────────────────────
|
// ─── Nav bar height ──────────────────────────────────────
|
||||||
const navBarHeight = ref('64px')
|
const navBarHeight = ref('64px')
|
||||||
|
|
||||||
@@ -189,7 +223,10 @@ async function handleGetPhone(e: {
|
|||||||
|
|
||||||
// ─── Save ─────────────────────────────────────────────────
|
// ─── Save ─────────────────────────────────────────────────
|
||||||
async function handleSave() {
|
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()
|
const nickname = form.value.nickname.trim()
|
||||||
if (!nickname) {
|
if (!nickname) {
|
||||||
@@ -206,7 +243,15 @@ async function handleSave() {
|
|||||||
await userStore.updateProfile({ nickname })
|
await userStore.updateProfile({ nickname })
|
||||||
originalNickname.value = nickname
|
originalNickname.value = nickname
|
||||||
form.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) {
|
} catch (err: unknown) {
|
||||||
const msg = err instanceof Error ? err.message : '保存失败,请重试'
|
const msg = err instanceof Error ? err.message : '保存失败,请重试'
|
||||||
uni.showToast({ title: msg, icon: 'none' })
|
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 ────────────────────────────────────────────
|
// ─── Lifecycle ────────────────────────────────────────────
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||||
await userStore.fetchProfile()
|
if (!isFromLogin.value) {
|
||||||
|
await userStore.fetchProfile()
|
||||||
|
}
|
||||||
if (userStore.user) {
|
if (userStore.user) {
|
||||||
form.value = { nickname: userStore.user.nickname }
|
form.value = { nickname: userStore.user.nickname }
|
||||||
originalNickname.value = userStore.user.nickname
|
originalNickname.value = userStore.user.nickname
|
||||||
@@ -229,7 +291,52 @@ onMounted(async () => {
|
|||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.info-page {
|
.info-page {
|
||||||
min-height: 100vh;
|
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 ──────────────────────────────────── */
|
/* ── Avatar section ──────────────────────────────────── */
|
||||||
@@ -238,9 +345,13 @@ onMounted(async () => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 56rpx 0 40rpx;
|
padding: 56rpx 0 40rpx;
|
||||||
background: #fff;
|
background: $bg-card;
|
||||||
margin-bottom: 24rpx;
|
margin-bottom: $spacing-md;
|
||||||
border-bottom: 1rpx solid #f0ece8;
|
border-bottom: 1rpx solid $border-color;
|
||||||
|
|
||||||
|
&--welcome {
|
||||||
|
padding-top: 40rpx;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar-btn {
|
.avatar-btn {
|
||||||
@@ -265,14 +376,14 @@ onMounted(async () => {
|
|||||||
width: 160rpx;
|
width: 160rpx;
|
||||||
height: 160rpx;
|
height: 160rpx;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 4rpx solid #f0f0f0;
|
border: 4rpx solid $border-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar-placeholder {
|
.avatar-placeholder {
|
||||||
width: 160rpx;
|
width: 160rpx;
|
||||||
height: 160rpx;
|
height: 160rpx;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: linear-gradient(135deg, $primary-dark, $primary-color);
|
background: linear-gradient(135deg, $brand-color, $accent-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -284,25 +395,45 @@ onMounted(async () => {
|
|||||||
color: #fff;
|
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 {
|
.avatar-name {
|
||||||
font-size: 34rpx;
|
font-size: 34rpx;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #1a1a1a;
|
color: $text-primary;
|
||||||
margin-bottom: 6rpx;
|
margin-bottom: 6rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar-hint {
|
.avatar-hint {
|
||||||
font-size: 22rpx;
|
font-size: 22rpx;
|
||||||
color: #bbb;
|
color: $text-hint;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Form card ───────────────────────────────────────── */
|
/* ── Form card ───────────────────────────────────────── */
|
||||||
.form-card {
|
.form-card {
|
||||||
background: #fff;
|
background: $bg-card;
|
||||||
border-radius: 20rpx;
|
border-radius: $radius-lg;
|
||||||
margin: 0 24rpx 20rpx;
|
margin: 0 $spacing-lg $spacing-md;
|
||||||
overflow: hidden;
|
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 {
|
.form-row {
|
||||||
@@ -310,7 +441,7 @@ onMounted(async () => {
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 32rpx 28rpx;
|
padding: 32rpx 28rpx;
|
||||||
border-bottom: 1rpx solid #f5f5f5;
|
border-bottom: 1rpx solid rgba($border-color, 0.5);
|
||||||
min-height: 100rpx;
|
min-height: 100rpx;
|
||||||
|
|
||||||
&--last {
|
&--last {
|
||||||
@@ -320,7 +451,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.form-label {
|
.form-label {
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
color: #555;
|
color: $text-secondary;
|
||||||
width: 120rpx;
|
width: 120rpx;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -329,7 +460,7 @@ onMounted(async () => {
|
|||||||
.form-input {
|
.form-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
color: #222;
|
color: $text-primary;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
min-height: 44rpx;
|
min-height: 44rpx;
|
||||||
@@ -338,13 +469,13 @@ onMounted(async () => {
|
|||||||
.form-value {
|
.form-value {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
color: #888;
|
color: $text-hint;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-arrow {
|
.form-arrow {
|
||||||
font-size: 36rpx;
|
font-size: 36rpx;
|
||||||
color: #ccc;
|
color: $text-hint;
|
||||||
margin-left: 8rpx;
|
margin-left: 8rpx;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
@@ -369,18 +500,18 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.bind-phone-text {
|
.bind-phone-text {
|
||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
color: $primary-dark;
|
color: $accent-color;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Read-only info card ──────────────────────────────── */
|
/* ── Read-only info card ──────────────────────────────── */
|
||||||
.info-card {
|
.info-card {
|
||||||
background: #fff;
|
background: $bg-card;
|
||||||
border-radius: 20rpx;
|
border-radius: $radius-lg;
|
||||||
margin: 0 24rpx 32rpx;
|
margin: 0 $spacing-lg $spacing-lg;
|
||||||
overflow: hidden;
|
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 {
|
.info-row {
|
||||||
@@ -389,7 +520,7 @@ onMounted(async () => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 28rpx 28rpx;
|
padding: 28rpx 28rpx;
|
||||||
border-bottom: 1rpx solid #f5f5f5;
|
border-bottom: 1rpx solid rgba($border-color, 0.5);
|
||||||
|
|
||||||
&--last {
|
&--last {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
@@ -398,33 +529,38 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.info-label {
|
.info-label {
|
||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
color: #999;
|
color: $text-hint;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-value {
|
.info-value {
|
||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
color: #555;
|
color: $text-secondary;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Save button ─────────────────────────────────────── */
|
/* ── Save button ─────────────────────────────────────── */
|
||||||
.save-wrap {
|
.save-wrap {
|
||||||
padding: 8rpx 24rpx 48rpx;
|
padding: 8rpx $spacing-lg 48rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.save-btn {
|
.save-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 96rpx;
|
height: 96rpx;
|
||||||
border-radius: 48rpx;
|
border-radius: 48rpx;
|
||||||
background: linear-gradient(90deg, #1a1a2e, #2d2d5e);
|
background: linear-gradient(135deg, $brand-color, #5e5045);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
box-shadow: 0 4rpx 20rpx rgba(26, 26, 46, 0.3);
|
box-shadow: 0 8rpx 24rpx rgba($brand-color, 0.25);
|
||||||
transition: opacity 0.2s;
|
transition: all 0.25s ease;
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
opacity: 0.85;
|
transform: scale(0.98);
|
||||||
|
box-shadow: 0 4rpx 12rpx rgba($brand-color, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
&--loading,
|
&--loading,
|
||||||
@@ -437,7 +573,17 @@ onMounted(async () => {
|
|||||||
.save-btn-text {
|
.save-btn-text {
|
||||||
font-size: 32rpx;
|
font-size: 32rpx;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: $primary-dark;
|
color: #ffffff;
|
||||||
letter-spacing: 2rpx;
|
letter-spacing: 2rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.skip-text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: $text-hint;
|
||||||
|
padding: 8rpx 24rpx;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
504
packages/app/src/pages/profile/invite.vue
Normal file
504
packages/app/src/pages/profile/invite.vue
Normal 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>
|
||||||
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="membership-page" :style="{ paddingTop: navBarHeight }">
|
<view class="membership-page" :style="{ paddingTop: navBarHeight }">
|
||||||
<CustomNavBar title="我的会员卡" show-back />
|
<CustomNavBar title="我的会员卡" show-back />
|
||||||
<!-- Pull-to-refresh scroll view -->
|
|
||||||
<scroll-view
|
<scroll-view
|
||||||
class="scroll"
|
class="scroll"
|
||||||
scroll-y
|
scroll-y
|
||||||
@@ -16,11 +15,14 @@
|
|||||||
|
|
||||||
<!-- Empty state -->
|
<!-- Empty state -->
|
||||||
<view v-else-if="allMemberships.length === 0" class="empty-wrap">
|
<view v-else-if="allMemberships.length === 0" class="empty-wrap">
|
||||||
<text class="empty-icon">💳</text>
|
<view class="empty-card">
|
||||||
<text class="empty-title">暂无会员卡</text>
|
<view class="empty-deco empty-deco--1" />
|
||||||
<text class="empty-sub">购买会员卡后即可预约课程</text>
|
<view class="empty-deco empty-deco--2" />
|
||||||
<view class="empty-btn" @tap="goStore">
|
<text class="empty-title">还没有会员卡</text>
|
||||||
<text class="empty-btn-text">去购买</text>
|
<text class="empty-sub">购买会员卡后即可预约课程</text>
|
||||||
|
<view class="empty-btn" @tap="goStore">
|
||||||
|
<text class="empty-btn-text">去选购</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -29,7 +31,6 @@
|
|||||||
<!-- Active cards -->
|
<!-- Active cards -->
|
||||||
<view v-if="activeMemberships.length > 0" class="group-section">
|
<view v-if="activeMemberships.length > 0" class="group-section">
|
||||||
<view class="group-header">
|
<view class="group-header">
|
||||||
<view class="group-dot group-dot--active" />
|
|
||||||
<text class="group-title">有效会员卡</text>
|
<text class="group-title">有效会员卡</text>
|
||||||
<text class="group-count">{{ activeMemberships.length }} 张</text>
|
<text class="group-count">{{ activeMemberships.length }} 张</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -37,56 +38,60 @@
|
|||||||
<view
|
<view
|
||||||
v-for="m in activeMemberships"
|
v-for="m in activeMemberships"
|
||||||
:key="m.id"
|
:key="m.id"
|
||||||
class="card-item"
|
class="mc"
|
||||||
|
:class="cardBgClass(m.cardType.type)"
|
||||||
>
|
>
|
||||||
<!-- Colored left border strip -->
|
<!-- Decorative circles -->
|
||||||
<view class="card-strip" :class="stripClass(m.cardType.type)" />
|
<view class="mc-deco mc-deco--1" />
|
||||||
|
<view class="mc-deco mc-deco--2" />
|
||||||
|
|
||||||
<!-- Card header (colored gradient) -->
|
<!-- Top row: name + status -->
|
||||||
<view class="card-header" :class="headerClass(m.cardType.type)">
|
<view class="mc-top">
|
||||||
<view class="card-header-left">
|
<view class="mc-name-area">
|
||||||
<text class="card-name">{{ m.cardType.name }}</text>
|
<text class="mc-name">{{ m.cardType.name }}</text>
|
||||||
<view class="card-type-badge">
|
<view class="mc-type-tag">
|
||||||
<text class="card-type-badge-text">{{ typeLabel(m.cardType.type) }}</text>
|
<text class="mc-type-text">{{ getCardTypeLabel(m.cardType.type) }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="status-badge status-badge--active">
|
<view class="mc-status mc-status--active">
|
||||||
<text class="status-badge-text">有效</text>
|
<view class="mc-status-dot" />
|
||||||
|
<text class="mc-status-text">有效</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Card body -->
|
<!-- Center: highlight number (times card) -->
|
||||||
<view class="card-body">
|
<view v-if="m.remainingTimes !== null" class="mc-center">
|
||||||
<!-- Times card: remaining times + progress -->
|
<text class="mc-big-num">{{ m.remainingTimes }}</text>
|
||||||
<template v-if="m.remainingTimes !== null">
|
<text class="mc-big-unit">次剩余</text>
|
||||||
<view class="highlight-row">
|
<view v-if="m.cardType.totalTimes" class="mc-progress">
|
||||||
<text class="highlight-label">剩余课时</text>
|
<view class="mc-progress-track">
|
||||||
<text class="highlight-value">
|
<view
|
||||||
<text class="highlight-number">{{ m.remainingTimes }}</text>
|
class="mc-progress-fill"
|
||||||
<text class="highlight-unit"> 次</text>
|
:style="{ width: getMembershipProgressWidth(m) }"
|
||||||
</text>
|
/>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="m.cardType.totalTimes" class="progress-wrap">
|
<text class="mc-progress-label">
|
||||||
<view class="progress-bar">
|
已用 {{ getMembershipUsedTimes(m) }},共 {{ m.cardType.totalTimes }} 次
|
||||||
<view
|
</text>
|
||||||
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>
|
|
||||||
</view>
|
</view>
|
||||||
<view class="info-row">
|
</view>
|
||||||
<text class="info-label">开始日期</text>
|
|
||||||
<text class="info-value">{{ m.startDate.slice(0, 10) }}</text>
|
<!-- 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>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -95,7 +100,6 @@
|
|||||||
<!-- Expired / used up cards -->
|
<!-- Expired / used up cards -->
|
||||||
<view v-if="inactiveMemberships.length > 0" class="group-section">
|
<view v-if="inactiveMemberships.length > 0" class="group-section">
|
||||||
<view class="group-header">
|
<view class="group-header">
|
||||||
<view class="group-dot group-dot--inactive" />
|
|
||||||
<text class="group-title">历史记录</text>
|
<text class="group-title">历史记录</text>
|
||||||
<text class="group-count">{{ inactiveMemberships.length }} 张</text>
|
<text class="group-count">{{ inactiveMemberships.length }} 张</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -103,28 +107,30 @@
|
|||||||
<view
|
<view
|
||||||
v-for="m in inactiveMemberships"
|
v-for="m in inactiveMemberships"
|
||||||
:key="m.id"
|
:key="m.id"
|
||||||
class="card-item card-item--inactive"
|
class="mc mc--inactive"
|
||||||
>
|
>
|
||||||
<view class="card-strip card-strip--inactive" />
|
<view class="mc-deco mc-deco--1" />
|
||||||
<view class="card-header card-header--inactive">
|
|
||||||
<view class="card-header-left">
|
<view class="mc-top">
|
||||||
<text class="card-name card-name--dim">{{ m.cardType.name }}</text>
|
<view class="mc-name-area">
|
||||||
<view class="card-type-badge card-type-badge--dim">
|
<text class="mc-name">{{ m.cardType.name }}</text>
|
||||||
<text class="card-type-badge-text">{{ typeLabel(m.cardType.type) }}</text>
|
<view class="mc-type-tag">
|
||||||
|
<text class="mc-type-text">{{ getCardTypeLabel(m.cardType.type) }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="status-badge" :class="statusBadgeClass(m.status)">
|
<view class="mc-status" :class="inactiveStatusClass(m.status)">
|
||||||
<text class="status-badge-text">{{ statusLabel(m.status) }}</text>
|
<text class="mc-status-text">{{ statusLabel(m.status) }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="card-body">
|
|
||||||
<view v-if="m.remainingTimes !== null" class="info-row">
|
<view class="mc-inactive-info">
|
||||||
<text class="info-label">剩余课时</text>
|
<view v-if="m.remainingTimes !== null" class="mc-date-item">
|
||||||
<text class="info-value">{{ m.remainingTimes }} 次</text>
|
<text class="mc-date-label">剩余</text>
|
||||||
|
<text class="mc-date-value">{{ m.remainingTimes }} 次</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="info-row">
|
<view class="mc-date-item">
|
||||||
<text class="info-label">有效期至</text>
|
<text class="mc-date-label">有效期至</text>
|
||||||
<text class="info-value">{{ m.expireDate.slice(0, 10) }}</text>
|
<text class="mc-date-value">{{ m.expireDate.slice(0, 10) }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -136,8 +142,7 @@
|
|||||||
|
|
||||||
<!-- Buy more FAB -->
|
<!-- Buy more FAB -->
|
||||||
<view class="fab" @tap="goStore">
|
<view class="fab" @tap="goStore">
|
||||||
<text class="fab-icon">+</text>
|
<text class="fab-text">+ 购买会员卡</text>
|
||||||
<text class="fab-text">购买会员卡</text>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
@@ -148,17 +153,15 @@ import type { MembershipWithCardType } from '@mp-pilates/shared'
|
|||||||
import { MembershipStatus, CardTypeCategory } from '@mp-pilates/shared'
|
import { MembershipStatus, CardTypeCategory } from '@mp-pilates/shared'
|
||||||
import { useUserStore } from '../../stores/user'
|
import { useUserStore } from '../../stores/user'
|
||||||
import { getSystemLayout } from '../../utils/system'
|
import { getSystemLayout } from '../../utils/system'
|
||||||
|
import { getCardTypeLabel, getMembershipProgressWidth, getMembershipUsedTimes } from '../../utils/format'
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
// ─── Nav bar height ──────────────────────────────────────
|
|
||||||
const navBarHeight = ref('64px')
|
const navBarHeight = ref('64px')
|
||||||
// ─── State ────────────────────────────────────────────────
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const refreshing = ref(false)
|
const refreshing = ref(false)
|
||||||
|
|
||||||
// ─── Computed from store ───────────────────────────────────
|
|
||||||
const allMemberships = computed(() => userStore.memberships as MembershipWithCardType[])
|
const allMemberships = computed(() => userStore.memberships as MembershipWithCardType[])
|
||||||
|
|
||||||
const activeMemberships = computed(() =>
|
const activeMemberships = computed(() =>
|
||||||
@@ -169,16 +172,6 @@ const inactiveMemberships = computed(() =>
|
|||||||
allMemberships.value.filter((m) => m.status !== MembershipStatus.ACTIVE),
|
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 {
|
function statusLabel(status: MembershipStatus): string {
|
||||||
const map: Record<MembershipStatus, string> = {
|
const map: Record<MembershipStatus, string> = {
|
||||||
[MembershipStatus.ACTIVE]: '有效',
|
[MembershipStatus.ACTIVE]: '有效',
|
||||||
@@ -188,36 +181,22 @@ function statusLabel(status: MembershipStatus): string {
|
|||||||
return map[status] ?? status
|
return map[status] ?? status
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusBadgeClass(status: MembershipStatus): string {
|
function inactiveStatusClass(status: MembershipStatus): string {
|
||||||
if (status === MembershipStatus.EXPIRED) return 'status-badge--expired'
|
if (status === MembershipStatus.USED_UP) return 'mc-status--used'
|
||||||
if (status === MembershipStatus.USED_UP) return 'status-badge--used'
|
return 'mc-status--expired'
|
||||||
return 'status-badge--expired'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripClass(type: CardTypeCategory): string {
|
function cardBgClass(type: CardTypeCategory): string {
|
||||||
if (type === CardTypeCategory.TRIAL) return 'card-strip--trial'
|
if (type === CardTypeCategory.TRIAL) return 'mc--trial'
|
||||||
if (type === CardTypeCategory.DURATION) return 'card-strip--duration'
|
if (type === CardTypeCategory.DURATION) return 'mc--duration'
|
||||||
return 'card-strip--times'
|
return 'mc--times'
|
||||||
}
|
}
|
||||||
|
|
||||||
function headerClass(type: CardTypeCategory): string {
|
function daysRemaining(m: MembershipWithCardType): number {
|
||||||
if (type === CardTypeCategory.TRIAL) return 'card-header--trial'
|
const diff = new Date(m.expireDate).getTime() - Date.now()
|
||||||
if (type === CardTypeCategory.DURATION) return 'card-header--duration'
|
return Math.max(0, Math.ceil(diff / 86_400_000))
|
||||||
return 'card-header--times'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
async function loadMemberships() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -239,7 +218,6 @@ function goStore() {
|
|||||||
uni.switchTab({ url: '/pages/home/index' })
|
uni.switchTab({ url: '/pages/home/index' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Lifecycle ────────────────────────────────────────────
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||||
loadMemberships()
|
loadMemberships()
|
||||||
@@ -249,313 +227,373 @@ onMounted(() => {
|
|||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.membership-page {
|
.membership-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #f5f3f0;
|
background: $bg-page;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scroll {
|
.scroll {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Loading ─────────────────────────────────────────── */
|
/* ── Loading ─────────────────────────────── */
|
||||||
.loading-wrap {
|
.loading-wrap {
|
||||||
padding: 24rpx;
|
padding: 24rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 20rpx;
|
gap: 24rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeleton-card {
|
.skeleton-card {
|
||||||
height: 220rpx;
|
height: 320rpx;
|
||||||
border-radius: 20rpx;
|
border-radius: 24rpx;
|
||||||
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
|
background: linear-gradient(90deg, #f0ece8 25%, #e8e4df 50%, #f0ece8 75%);
|
||||||
background-size: 400% 100%;
|
background-size: 400% 100%;
|
||||||
animation: shimmer 1.4s infinite;
|
animation: shimmer 1.4s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Empty ───────────────────────────────────────────── */
|
/* ── Empty ────────────────────────────────── */
|
||||||
.empty-wrap {
|
.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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
gap: 16rpx;
|
||||||
padding: 120rpx 40rpx;
|
|
||||||
gap: 20rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-icon {
|
.empty-deco {
|
||||||
font-size: 80rpx;
|
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 {
|
.empty-title {
|
||||||
font-size: 32rpx;
|
font-size: 34rpx;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
color: #333;
|
color: $brand-color;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-sub {
|
.empty-sub {
|
||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
color: #999;
|
color: $text-secondary;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-btn {
|
.empty-btn {
|
||||||
margin-top: 12rpx;
|
margin-top: 16rpx;
|
||||||
padding: 22rpx 60rpx;
|
padding: 20rpx 56rpx;
|
||||||
border-radius: 44rpx;
|
border-radius: 40rpx;
|
||||||
background: $primary-dark;
|
background: rgba(74, 64, 53, 0.12);
|
||||||
box-shadow: 0 4rpx 16rpx rgba(201, 168, 124, 0.35);
|
z-index: 1;
|
||||||
|
|
||||||
|
&:active { background: rgba(74, 64, 53, 0.18); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-btn-text {
|
.empty-btn-text {
|
||||||
font-size: 30rpx;
|
font-size: 28rpx;
|
||||||
color: #fff;
|
color: $brand-color;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── List ────────────────────────────────────────────── */
|
/* ── List ─────────────────────────────────── */
|
||||||
.list {
|
.list {
|
||||||
padding: 24rpx 24rpx 0;
|
padding: 16rpx 24rpx 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Group section ───────────────────────────────────── */
|
/* ── Group ────────────────────────────────── */
|
||||||
.group-section {
|
.group-section {
|
||||||
margin-bottom: 8rpx;
|
margin-bottom: 16rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-header {
|
.group-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10rpx;
|
justify-content: space-between;
|
||||||
padding: 8rpx 4rpx 14rpx;
|
padding: 12rpx 8rpx 16rpx;
|
||||||
}
|
|
||||||
|
|
||||||
.group-dot {
|
|
||||||
width: 12rpx;
|
|
||||||
height: 12rpx;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
&--active { background: #4caf50; }
|
|
||||||
&--inactive { background: #bbb; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-title {
|
.group-title {
|
||||||
font-size: 26rpx;
|
font-size: 28rpx;
|
||||||
color: #555;
|
color: $text-primary;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
flex: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-count {
|
.group-count {
|
||||||
font-size: 22rpx;
|
font-size: 22rpx;
|
||||||
color: #bbb;
|
color: $text-hint;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Card item ───────────────────────────────────────── */
|
/* ══════════════════════════════════════════════
|
||||||
.card-item {
|
MEMBERSHIP CARD (mc)
|
||||||
background: #fff;
|
══════════════════════════════════════════════ */
|
||||||
border-radius: 20rpx;
|
.mc {
|
||||||
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-bottom: 16rpx;
|
border-radius: 24rpx;
|
||||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.07);
|
padding: 28rpx 32rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
gap: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
&--inactive {
|
/* Card type backgrounds */
|
||||||
opacity: 0.72;
|
.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 */
|
/* ── Top row ──────────────────────────────── */
|
||||||
.card-strip {
|
.mc-top {
|
||||||
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;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
align-items: flex-start;
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
z-index: 1;
|
||||||
&--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; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-header-left {
|
.mc-name-area {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8rpx;
|
gap: 8rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-name {
|
.mc-name {
|
||||||
font-size: 32rpx;
|
font-size: 34rpx;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #fff;
|
color: #2C2420;
|
||||||
|
line-height: 1.2;
|
||||||
&--dim { color: #ddd; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-type-badge {
|
.mc-type-tag {
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
padding: 4rpx 14rpx;
|
padding: 4rpx 14rpx;
|
||||||
border-radius: 12rpx;
|
border-radius: 10rpx;
|
||||||
background: rgba(255, 255, 255, 0.15);
|
background: rgba(44, 36, 32, 0.1);
|
||||||
border: 1rpx solid rgba(255, 255, 255, 0.25);
|
|
||||||
|
|
||||||
&--dim {
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-type-badge-text {
|
.mc-type-text {
|
||||||
font-size: 20rpx;
|
font-size: 20rpx;
|
||||||
color: rgba(255, 255, 255, 0.85);
|
color: rgba(44, 36, 32, 0.6);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Status badge */
|
/* Status */
|
||||||
.status-badge {
|
.mc-status {
|
||||||
padding: 8rpx 20rpx;
|
display: flex;
|
||||||
border-radius: 20rpx;
|
align-items: center;
|
||||||
border: 1rpx solid rgba(255, 255, 255, 0.35);
|
gap: 8rpx;
|
||||||
|
padding: 6rpx 16rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
flex-shrink: 0;
|
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;
|
font-size: 22rpx;
|
||||||
color: #fff;
|
color: #2C2420;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Card body */
|
/* ── Center: big number ───────────────────── */
|
||||||
.card-body {
|
.mc-center {
|
||||||
padding: 20rpx 28rpx 24rpx;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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;
|
align-items: center;
|
||||||
justify-content: space-between;
|
padding: 8rpx 0;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-label {
|
.mc-big-num {
|
||||||
font-size: 26rpx;
|
font-size: 80rpx;
|
||||||
color: #999;
|
font-weight: 800;
|
||||||
|
color: #2C2420;
|
||||||
|
line-height: 1;
|
||||||
|
font-family: 'DIN Alternate', 'Helvetica Neue', Arial, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-value {
|
.mc-big-unit {
|
||||||
font-size: 26rpx;
|
font-size: 24rpx;
|
||||||
color: #333;
|
color: rgba(44, 36, 32, 0.55);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
margin-top: 4rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Progress bar ────────────────────────────────────── */
|
/* Progress */
|
||||||
.progress-wrap {
|
.mc-progress {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400rpx;
|
||||||
|
margin-top: 20rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8rpx;
|
gap: 8rpx;
|
||||||
margin-bottom: 6rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar {
|
.mc-progress-track {
|
||||||
height: 8rpx;
|
height: 10rpx;
|
||||||
background: #f0f0f0;
|
background: rgba(44, 36, 32, 0.1);
|
||||||
border-radius: 4rpx;
|
border-radius: 5rpx;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-fill {
|
.mc-progress-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(90deg, $primary-dark, $primary-color);
|
background: rgba(44, 36, 32, 0.35);
|
||||||
border-radius: 4rpx;
|
border-radius: 5rpx;
|
||||||
transition: width 0.4s ease;
|
transition: width 0.4s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-label {
|
.mc-progress-label {
|
||||||
font-size: 22rpx;
|
font-size: 20rpx;
|
||||||
color: #bbb;
|
color: rgba(44, 36, 32, 0.45);
|
||||||
text-align: right;
|
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 {
|
.fab {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: calc(32rpx + env(safe-area-inset-bottom));
|
bottom: calc(32rpx + env(safe-area-inset-bottom));
|
||||||
right: 32rpx;
|
right: 32rpx;
|
||||||
background: #1a1a2e;
|
background: $brand-color;
|
||||||
border-radius: 44rpx;
|
border-radius: 44rpx;
|
||||||
padding: 22rpx 36rpx;
|
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;
|
z-index: 100;
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8rpx;
|
|
||||||
|
|
||||||
&:active {
|
&:active { opacity: 0.85; }
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.fab-icon {
|
|
||||||
font-size: 36rpx;
|
|
||||||
color: $primary-dark;
|
|
||||||
font-weight: 300;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.fab-text {
|
.fab-text {
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
color: $primary-dark;
|
color: #fff;
|
||||||
letter-spacing: 1rpx;
|
letter-spacing: 1rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Spacer ──────────────────────────────────────────── */
|
/* ── Spacer ───────────────────────────────── */
|
||||||
.scroll-bottom-spacer {
|
.scroll-bottom-spacer {
|
||||||
height: 120rpx;
|
height: 140rpx;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
582
packages/app/src/pages/profile/teaching-schedule.vue
Normal file
582
packages/app/src/pages/profile/teaching-schedule.vue
Normal 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>
|
||||||
491
packages/app/src/pages/teacher/detail.vue
Normal file
491
packages/app/src/pages/teacher/detail.vue
Normal 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>
|
||||||
BIN
packages/app/src/static/courseBg.png
Normal file
BIN
packages/app/src/static/courseBg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
@@ -2,8 +2,6 @@ import { defineStore } from 'pinia'
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { get, post, put, del } from '../utils/request'
|
import { get, post, put, del } from '../utils/request'
|
||||||
import type {
|
import type {
|
||||||
WeekTemplate,
|
|
||||||
WeekTemplateInput,
|
|
||||||
CardType,
|
CardType,
|
||||||
CreateCardTypeDto,
|
CreateCardTypeDto,
|
||||||
UpdateCardTypeDto,
|
UpdateCardTypeDto,
|
||||||
@@ -15,8 +13,40 @@ import type {
|
|||||||
PaginatedData,
|
PaginatedData,
|
||||||
ScheduleSlotPreview,
|
ScheduleSlotPreview,
|
||||||
PublishDaySlotsDto,
|
PublishDaySlotsDto,
|
||||||
|
FlashSaleAdminItem,
|
||||||
|
CreateFlashSaleDto,
|
||||||
|
UpdateFlashSaleDto,
|
||||||
|
CreateStudioUploadCredentialDto,
|
||||||
|
StudioUploadCredential,
|
||||||
} from '@mp-pilates/shared'
|
} 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 {
|
export interface AdminStats {
|
||||||
todayBookings: number
|
todayBookings: number
|
||||||
totalOrders: number
|
totalOrders: number
|
||||||
@@ -34,22 +64,26 @@ export interface MemberSummary {
|
|||||||
cancelledBookings: number
|
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', () => {
|
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 ───────────────────────────────────────────────────
|
// ── Card types ───────────────────────────────────────────────────
|
||||||
const cardTypes = ref<CardType[]>([])
|
const cardTypes = ref<CardType[]>([])
|
||||||
|
|
||||||
@@ -60,13 +94,13 @@ export const useAdminStore = defineStore('admin', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createCardType(dto: CreateCardTypeDto): Promise<CardType> {
|
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()
|
await fetchCardTypes()
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateCardType(id: string, dto: UpdateCardTypeDto): Promise<CardType> {
|
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()
|
await fetchCardTypes()
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
@@ -87,18 +121,31 @@ export const useAdminStore = defineStore('admin', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveStudioConfig(dto: UpdateStudioConfigDto): Promise<StudioConfig> {
|
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
|
studioConfig.value = data
|
||||||
return 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 ───────────────────────────────────────────────────────
|
// ── Orders ───────────────────────────────────────────────────────
|
||||||
async function fetchAdminOrders(params: {
|
async function fetchAdminOrders(params: {
|
||||||
page?: number
|
page?: number
|
||||||
limit?: number
|
limit?: number
|
||||||
status?: string
|
status?: string
|
||||||
}): Promise<PaginatedData<OrderWithDetails>> {
|
}): 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 ─────────────────────────────────────────────────────
|
// ── Bookings ─────────────────────────────────────────────────────
|
||||||
@@ -115,22 +162,43 @@ export const useAdminStore = defineStore('admin', () => {
|
|||||||
page?: number
|
page?: number
|
||||||
limit?: number
|
limit?: number
|
||||||
search?: string
|
search?: string
|
||||||
|
cardType?: string
|
||||||
}): Promise<PaginatedData<MemberSummary>> {
|
}): Promise<PaginatedData<MemberSummary>> {
|
||||||
// Filter out undefined/empty values to avoid sending "undefined" as string
|
|
||||||
const cleanParams: Record<string, unknown> = {}
|
const cleanParams: Record<string, unknown> = {}
|
||||||
if (params?.page != null) cleanParams.page = params.page
|
if (params?.page != null) cleanParams.page = params.page
|
||||||
if (params?.limit != null) cleanParams.limit = params.limit
|
if (params?.limit != null) cleanParams.limit = params.limit
|
||||||
if (params?.search) cleanParams.search = params.search
|
if (params?.search) cleanParams.search = params.search
|
||||||
|
if (params?.cardType) cleanParams.cardType = params.cardType
|
||||||
return get<PaginatedData<MemberSummary>>('/admin/members', cleanParams)
|
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 ───────────────────────────────────────────────────
|
// ── Time slots ───────────────────────────────────────────────────
|
||||||
async function fetchSlotsByDate(date: string): Promise<TimeSlot[]> {
|
async function fetchSlotsByDate(date: string): Promise<TimeSlot[]> {
|
||||||
return get<TimeSlot[]>('/admin/time-slots', { date })
|
return get<TimeSlot[]>('/admin/time-slots', { date })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createManualSlot(dto: CreateManualSlotDto): Promise<TimeSlot> {
|
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> {
|
async function closeSlot(id: string): Promise<TimeSlot> {
|
||||||
@@ -166,16 +234,32 @@ export const useAdminStore = defineStore('admin', () => {
|
|||||||
return get<AdminStats>('/admin/stats')
|
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 {
|
return {
|
||||||
// State
|
// State
|
||||||
weekTemplates,
|
|
||||||
cardTypes,
|
cardTypes,
|
||||||
studioConfig,
|
studioConfig,
|
||||||
schedulePreview,
|
schedulePreview,
|
||||||
scheduleLoading,
|
scheduleLoading,
|
||||||
// Week templates
|
|
||||||
fetchWeekTemplates,
|
|
||||||
saveWeekTemplates,
|
|
||||||
// Card types
|
// Card types
|
||||||
fetchCardTypes,
|
fetchCardTypes,
|
||||||
createCardType,
|
createCardType,
|
||||||
@@ -184,12 +268,16 @@ export const useAdminStore = defineStore('admin', () => {
|
|||||||
// Studio
|
// Studio
|
||||||
fetchStudioConfig,
|
fetchStudioConfig,
|
||||||
saveStudioConfig,
|
saveStudioConfig,
|
||||||
|
createStudioUploadCredential,
|
||||||
// Orders
|
// Orders
|
||||||
fetchAdminOrders,
|
fetchAdminOrders,
|
||||||
// Bookings
|
// Bookings
|
||||||
fetchAdminBookings,
|
fetchAdminBookings,
|
||||||
// Members
|
// Members
|
||||||
fetchMembers,
|
fetchMembers,
|
||||||
|
getUserMembership,
|
||||||
|
updateUserMembership,
|
||||||
|
deleteUserMembership,
|
||||||
// Time slots
|
// Time slots
|
||||||
fetchSlotsByDate,
|
fetchSlotsByDate,
|
||||||
createManualSlot,
|
createManualSlot,
|
||||||
@@ -200,5 +288,10 @@ export const useAdminStore = defineStore('admin', () => {
|
|||||||
publishDaySlots,
|
publishDaySlots,
|
||||||
// Stats
|
// Stats
|
||||||
fetchDashboardStats,
|
fetchDashboardStats,
|
||||||
|
// Flash sales
|
||||||
|
fetchFlashSales,
|
||||||
|
createFlashSale,
|
||||||
|
updateFlashSale,
|
||||||
|
deleteFlashSale,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
BookingWithUser,
|
BookingWithUser,
|
||||||
BookingStatusHistory,
|
BookingStatusHistory,
|
||||||
CreateBookingDto,
|
CreateBookingDto,
|
||||||
|
TeachingScheduleSlot,
|
||||||
} from '@mp-pilates/shared'
|
} from '@mp-pilates/shared'
|
||||||
import { get, post, put } from '../utils/request'
|
import { get, post, put } from '../utils/request'
|
||||||
|
|
||||||
@@ -21,8 +22,10 @@ export const useBookingStore = defineStore('booking', () => {
|
|||||||
const slots = ref<readonly TimeSlotWithBookingStatus[]>([])
|
const slots = ref<readonly TimeSlotWithBookingStatus[]>([])
|
||||||
const myBookings = ref<readonly BookingWithDetails[]>([])
|
const myBookings = ref<readonly BookingWithDetails[]>([])
|
||||||
const upcomingBookings = ref<readonly BookingWithDetails[]>([])
|
const upcomingBookings = ref<readonly BookingWithDetails[]>([])
|
||||||
|
const teachingSchedule = ref<readonly TeachingScheduleSlot[]>([])
|
||||||
const loadingSlots = ref(false)
|
const loadingSlots = ref(false)
|
||||||
const loadingBookings = ref(false)
|
const loadingBookings = ref(false)
|
||||||
|
const loadingTeachingSchedule = ref(false)
|
||||||
|
|
||||||
async function fetchSlots(date: string) {
|
async function fetchSlots(date: string) {
|
||||||
loadingSlots.value = true
|
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 ──────────────────────────────────────────────────────
|
// ─── Admin methods ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function fetchAllAdminBookings(
|
async function fetchAllAdminBookings(
|
||||||
@@ -115,22 +133,31 @@ export const useBookingStore = defineStore('booking', () => {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchSlotById(slotId: string) {
|
||||||
|
const result = await get<TimeSlotWithBookingStatus>(`/time-slot/${slotId}`)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
slots,
|
slots,
|
||||||
myBookings,
|
myBookings,
|
||||||
upcomingBookings,
|
upcomingBookings,
|
||||||
|
teachingSchedule,
|
||||||
loadingSlots,
|
loadingSlots,
|
||||||
loadingBookings,
|
loadingBookings,
|
||||||
|
loadingTeachingSchedule,
|
||||||
fetchSlots,
|
fetchSlots,
|
||||||
createBooking,
|
createBooking,
|
||||||
cancelBooking,
|
cancelBooking,
|
||||||
fetchMyBookings,
|
fetchMyBookings,
|
||||||
fetchUpcomingBookings,
|
fetchUpcomingBookings,
|
||||||
|
fetchTeachingSchedule,
|
||||||
fetchAllAdminBookings,
|
fetchAllAdminBookings,
|
||||||
confirmBooking,
|
confirmBooking,
|
||||||
completeBooking,
|
completeBooking,
|
||||||
markNoShow,
|
markNoShow,
|
||||||
fetchBookingHistory,
|
fetchBookingHistory,
|
||||||
|
fetchSlotById,
|
||||||
fetchBookingById,
|
fetchBookingById,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
43
packages/app/src/stores/flash-sale.ts
Normal file
43
packages/app/src/stores/flash-sale.ts
Normal 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
26
packages/app/src/stores/invite.ts
Normal file
26
packages/app/src/stores/invite.ts
Normal 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
@@ -8,6 +8,12 @@ import type {
|
|||||||
import { UserRole, MembershipStatus } from '@mp-pilates/shared'
|
import { UserRole, MembershipStatus } from '@mp-pilates/shared'
|
||||||
import { wxLogin, isLoggedIn, logout as authLogout } from '../utils/auth'
|
import { wxLogin, isLoggedIn, logout as authLogout } from '../utils/auth'
|
||||||
import { get, put } from '../utils/request'
|
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', () => {
|
export const useUserStore = defineStore('user', () => {
|
||||||
// State
|
// State
|
||||||
@@ -26,6 +32,7 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
memberships.value.filter((m) => m.status === MembershipStatus.ACTIVE),
|
memberships.value.filter((m) => m.status === MembershipStatus.ACTIVE),
|
||||||
)
|
)
|
||||||
const hasValidMembership = computed(() => activeMemberships.value.length > 0)
|
const hasValidMembership = computed(() => activeMemberships.value.length > 0)
|
||||||
|
const inviteShareEligible = computed(() => !!user.value?.inviteShareEligible)
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
async function login() {
|
async function login() {
|
||||||
@@ -33,17 +40,34 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
const result = await wxLogin()
|
const result = await wxLogin()
|
||||||
token.value = result.token
|
token.value = result.token
|
||||||
user.value = result.user
|
user.value = result.user
|
||||||
return result.user
|
syncSubscriptionTemplates(result.user)
|
||||||
|
return { user: result.user, isNewUser: result.isNewUser }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Login failed:', err)
|
console.error('Login failed:', err)
|
||||||
throw 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() {
|
async function fetchProfile() {
|
||||||
if (!isLoggedIn()) return
|
if (!isLoggedIn()) return
|
||||||
try {
|
try {
|
||||||
user.value = await get<UserProfileResponse>('/user/profile')
|
user.value = await get<UserProfileResponse>('/user/profile')
|
||||||
|
syncSubscriptionTemplates(user.value)
|
||||||
|
return user.value
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Fetch profile failed:', err)
|
console.error('Fetch profile failed:', err)
|
||||||
}
|
}
|
||||||
@@ -70,9 +94,15 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
async function updateProfile(data: { nickname?: string; avatarUrl?: string }) {
|
async function updateProfile(data: { nickname?: string; avatarUrl?: string }) {
|
||||||
const updated = await put<UserProfileResponse>('/user/profile', data)
|
const updated = await put<UserProfileResponse>('/user/profile', data)
|
||||||
user.value = updated
|
user.value = updated
|
||||||
|
syncSubscriptionTemplates(updated)
|
||||||
return updated
|
return updated
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setProfile(profile: UserProfileResponse) {
|
||||||
|
user.value = profile
|
||||||
|
syncSubscriptionTemplates(profile)
|
||||||
|
}
|
||||||
|
|
||||||
function checkAuth() {
|
function checkAuth() {
|
||||||
if (isLoggedIn()) {
|
if (isLoggedIn()) {
|
||||||
fetchProfile()
|
fetchProfile()
|
||||||
@@ -82,6 +112,7 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
authLogout()
|
authLogout()
|
||||||
|
resetSubscriptionMessageTemplateCache()
|
||||||
token.value = ''
|
token.value = ''
|
||||||
user.value = null
|
user.value = null
|
||||||
stats.value = null
|
stats.value = null
|
||||||
@@ -98,11 +129,14 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
isAdmin,
|
isAdmin,
|
||||||
activeMemberships,
|
activeMemberships,
|
||||||
hasValidMembership,
|
hasValidMembership,
|
||||||
|
inviteShareEligible,
|
||||||
login,
|
login,
|
||||||
|
loginWithSetup,
|
||||||
fetchProfile,
|
fetchProfile,
|
||||||
fetchStats,
|
fetchStats,
|
||||||
fetchMemberships,
|
fetchMemberships,
|
||||||
updateProfile,
|
updateProfile,
|
||||||
|
setProfile,
|
||||||
checkAuth,
|
checkAuth,
|
||||||
logout,
|
logout,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,40 +4,82 @@ import type { UserProfileResponse } from '@mp-pilates/shared'
|
|||||||
interface LoginResponse {
|
interface LoginResponse {
|
||||||
readonly token: string
|
readonly token: string
|
||||||
readonly user: UserProfileResponse
|
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> {
|
export async function wxLogin(): Promise<LoginResponse> {
|
||||||
|
const inviterId = uni.getStorageSync('invite_inviter_id') as string
|
||||||
|
|
||||||
|
await ensurePrivacyAuthorization()
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// Step 1:静默登录,获取 code
|
// Step 1:静默登录,获取 code
|
||||||
uni.login({
|
uni.login({
|
||||||
provider: 'weixin',
|
provider: 'weixin',
|
||||||
success: async (loginRes) => {
|
success: async (loginRes) => {
|
||||||
try {
|
try {
|
||||||
// Step 2: 获取用户微信头像和昵称
|
if (!loginRes.code) {
|
||||||
let nickname: string | undefined
|
reject(new Error('微信登录失败,请重试'))
|
||||||
let avatarUrl: string | undefined
|
return
|
||||||
await new Promise<void>((res) => {
|
}
|
||||||
uni.getUserProfile({
|
|
||||||
desc: '用于完善个人资料',
|
|
||||||
success: (profileRes) => {
|
|
||||||
nickname = profileRes.userInfo.nickName
|
|
||||||
avatarUrl = profileRes.userInfo.avatarUrl
|
|
||||||
res()
|
|
||||||
},
|
|
||||||
fail: () => {
|
|
||||||
// 用户拒绝授权,仍可继续登录
|
|
||||||
res()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Step 3: 发送登录请求
|
// Step 2: 发送登录请求
|
||||||
|
// 注:uni.getUserProfile 已被微信废弃(基础库 2.27.1+),
|
||||||
|
// 新用户的昵称/头像由后端生成默认值,用户可在个人资料页修改
|
||||||
const result = await post<LoginResponse>('/auth/login', {
|
const result = await post<LoginResponse>('/auth/login', {
|
||||||
code: loginRes.code,
|
code: loginRes.code,
|
||||||
nickname,
|
inviterId: inviterId || undefined,
|
||||||
avatarUrl,
|
|
||||||
})
|
})
|
||||||
uni.setStorageSync('token', result.token)
|
uni.setStorageSync('token', result.token)
|
||||||
|
if (result.isNewUser && inviterId) {
|
||||||
|
uni.removeStorageSync('invite_inviter_id')
|
||||||
|
}
|
||||||
resolve(result)
|
resolve(result)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
reject(err)
|
reject(err)
|
||||||
|
|||||||
@@ -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 {
|
export function formatPrice(cents: number): string {
|
||||||
return (cents / 100).toFixed(2)
|
return (cents / 100).toFixed(2)
|
||||||
@@ -44,3 +53,105 @@ export function getDateRange(days: number): ReadonlyArray<{ readonly date: strin
|
|||||||
}
|
}
|
||||||
return result
|
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')}`
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +1,7 @@
|
|||||||
import type { ApiResponse, PaginatedData } from '@mp-pilates/shared'
|
import type { ApiResponse, PaginatedData } from '@mp-pilates/shared'
|
||||||
|
|
||||||
const BASE_URL = (() => {
|
// 统一使用线上服务地址
|
||||||
try {
|
const BASE_URL = 'https://focus.richarjiang.com/api'
|
||||||
const { miniProgram } = uni.getAccountInfoSync()
|
|
||||||
if (miniProgram.envVersion !== 'develop') {
|
|
||||||
return 'https://focus.richarjiang.com/api'
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// 非小程序环境,使用开发地址
|
|
||||||
}
|
|
||||||
return 'http://localhost:3000/api'
|
|
||||||
})()
|
|
||||||
|
|
||||||
interface RequestOptions {
|
interface RequestOptions {
|
||||||
readonly url: string
|
readonly url: string
|
||||||
|
|||||||
4
packages/app/src/utils/routes.ts
Normal file
4
packages/app/src/utils/routes.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export const ROUTES = {
|
||||||
|
PROFILE_INFO: '/pages/profile/info',
|
||||||
|
PROFILE_INFO_FIRST_LOGIN: '/pages/profile/info?from=login',
|
||||||
|
} as const
|
||||||
72
packages/app/src/utils/studio-upload.ts
Normal file
72
packages/app/src/utils/studio-upload.ts
Normal 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
|
||||||
|
}
|
||||||
62
packages/app/src/utils/teacher.ts
Normal file
62
packages/app/src/utils/teacher.ts
Normal 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 分钟' },
|
||||||
|
],
|
||||||
|
}
|
||||||
256
packages/app/src/utils/wechat-subscription.ts
Normal file
256
packages/app/src/utils/wechat-subscription.ts
Normal 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
35
packages/server/.env
Normal 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
|
||||||
13
packages/server/.env.example
Normal file
13
packages/server/.env.example
Normal 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
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
"prisma:migrate": "prisma migrate dev",
|
"prisma:migrate": "prisma migrate dev",
|
||||||
"prisma:seed": "ts-node prisma/seed.ts",
|
"prisma:seed": "ts-node prisma/seed.ts",
|
||||||
|
"studio:seed-gallery": "ts-node prisma/update-studio-gallery.ts",
|
||||||
"lint": "eslint \"{src,test}/**/*.ts\""
|
"lint": "eslint \"{src,test}/**/*.ts\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -52,9 +53,9 @@
|
|||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"moduleFileExtensions": [
|
"moduleFileExtensions": [
|
||||||
|
"ts",
|
||||||
"js",
|
"js",
|
||||||
"json",
|
"json"
|
||||||
"ts"
|
|
||||||
],
|
],
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"testRegex": ".*\\.spec\\.ts$",
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
@@ -69,6 +70,7 @@
|
|||||||
"coverageDirectory": "../coverage",
|
"coverageDirectory": "../coverage",
|
||||||
"testEnvironment": "node",
|
"testEnvironment": "node",
|
||||||
"moduleNameMapper": {
|
"moduleNameMapper": {
|
||||||
|
"^@mp-pilates/shared$": "<rootDir>/../../shared/src/index.ts",
|
||||||
"^@mp-pilates/shared(.*)$": "<rootDir>/../../shared/src$1"
|
"^@mp-pilates/shared(.*)$": "<rootDir>/../../shared/src$1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -51,26 +51,75 @@ enum OrderStatus {
|
|||||||
REFUNDED
|
REFUNDED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum FlashSaleStatus {
|
||||||
|
DRAFT
|
||||||
|
ACTIVE
|
||||||
|
ENDED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FlashSaleOrderStatus {
|
||||||
|
RESERVED
|
||||||
|
PAID
|
||||||
|
EXPIRED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum InviteReferralStatus {
|
||||||
|
REGISTERED
|
||||||
|
TRIAL_PURCHASED
|
||||||
|
QUALIFIED
|
||||||
|
}
|
||||||
|
|
||||||
// ===== Models =====
|
// ===== Models =====
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
openid String @unique
|
openid String @unique
|
||||||
unionid String?
|
unionid String?
|
||||||
phone String?
|
phone String?
|
||||||
nickname String @default("")
|
nickname String @default("")
|
||||||
avatarUrl String? @map("avatar_url")
|
avatarUrl String? @map("avatar_url")
|
||||||
role UserRole @default(MEMBER)
|
role UserRole @default(MEMBER)
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
adminBookingSubscriptionCount Int @default(0) @map("admin_booking_subscription_count")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
memberships Membership[]
|
memberships Membership[]
|
||||||
bookings Booking[]
|
bookings Booking[]
|
||||||
orders Order[]
|
orders Order[]
|
||||||
|
flashSaleOrders FlashSaleOrder[]
|
||||||
|
subscriptionMessageConsents SubscriptionMessageConsent[]
|
||||||
|
sentInviteReferrals InviteReferral[] @relation("InviteReferralInviter")
|
||||||
|
receivedInviteReferral InviteReferral[] @relation("InviteReferralInvitee")
|
||||||
|
inviteRewardGrants InviteRewardGrant[] @relation("InviteRewardGrantInviter")
|
||||||
|
|
||||||
@@map("users")
|
@@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 {
|
model CardType {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String
|
name String
|
||||||
@@ -80,6 +129,7 @@ model CardType {
|
|||||||
price Decimal @db.Decimal(10, 0)
|
price Decimal @db.Decimal(10, 0)
|
||||||
originalPrice Decimal? @map("original_price") @db.Decimal(10, 0)
|
originalPrice Decimal? @map("original_price") @db.Decimal(10, 0)
|
||||||
description String?
|
description String?
|
||||||
|
coverUrl String? @map("cover_url")
|
||||||
isActive Boolean @default(true) @map("is_active")
|
isActive Boolean @default(true) @map("is_active")
|
||||||
sortOrder Int @default(0) @map("sort_order")
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
@@ -87,6 +137,7 @@ model CardType {
|
|||||||
|
|
||||||
memberships Membership[]
|
memberships Membership[]
|
||||||
orders Order[]
|
orders Order[]
|
||||||
|
flashSales FlashSale[]
|
||||||
|
|
||||||
@@map("card_types")
|
@@map("card_types")
|
||||||
}
|
}
|
||||||
@@ -105,6 +156,7 @@ model Membership {
|
|||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
cardType CardType @relation(fields: [cardTypeId], references: [id])
|
cardType CardType @relation(fields: [cardTypeId], references: [id])
|
||||||
bookings Booking[]
|
bookings Booking[]
|
||||||
|
inviteRewardGrants InviteRewardGrant[]
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@ -164,6 +216,7 @@ model Booking {
|
|||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
timeSlot TimeSlot @relation(fields: [timeSlotId], references: [id])
|
timeSlot TimeSlot @relation(fields: [timeSlotId], references: [id])
|
||||||
membership Membership @relation(fields: [membershipId], references: [id])
|
membership Membership @relation(fields: [membershipId], references: [id])
|
||||||
|
qualifiedInviteReferrals InviteReferral[]
|
||||||
|
|
||||||
statusHistory BookingStatusHistory[]
|
statusHistory BookingStatusHistory[]
|
||||||
|
|
||||||
@@ -197,17 +250,61 @@ model Order {
|
|||||||
status OrderStatus @default(PENDING)
|
status OrderStatus @default(PENDING)
|
||||||
wxTransactionId String? @map("wx_transaction_id")
|
wxTransactionId String? @map("wx_transaction_id")
|
||||||
paidAt DateTime? @map("paid_at")
|
paidAt DateTime? @map("paid_at")
|
||||||
|
flashSaleId String? @map("flash_sale_id")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
cardType CardType @relation(fields: [cardTypeId], references: [id])
|
cardType CardType @relation(fields: [cardTypeId], references: [id])
|
||||||
|
flashSaleOrder FlashSaleOrder?
|
||||||
|
inviteReferrals InviteReferral[]
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@map("orders")
|
@@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 {
|
model StudioConfig {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String
|
name String
|
||||||
@@ -223,3 +320,48 @@ model StudioConfig {
|
|||||||
|
|
||||||
@@map("studio_config")
|
@@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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ async function main() {
|
|||||||
openid: 'admin_test_openid',
|
openid: 'admin_test_openid',
|
||||||
nickname: '教练',
|
nickname: '教练',
|
||||||
role: UserRole.ADMIN,
|
role: UserRole.ADMIN,
|
||||||
|
adminBookingSubscriptionCount: 0,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
console.log(' ✅ Admin user created')
|
console.log(' ✅ Admin user created')
|
||||||
|
|||||||
39
packages/server/prisma/update-studio-gallery.ts
Normal file
39
packages/server/prisma/update-studio-gallery.ts
Normal 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()
|
||||||
|
})
|
||||||
@@ -11,6 +11,8 @@ import { BookingModule } from './booking/booking.module'
|
|||||||
import { SchedulerModule } from './scheduler/scheduler.module'
|
import { SchedulerModule } from './scheduler/scheduler.module'
|
||||||
import { PaymentModule } from './payment/payment.module'
|
import { PaymentModule } from './payment/payment.module'
|
||||||
import { AdminModule } from './admin/admin.module'
|
import { AdminModule } from './admin/admin.module'
|
||||||
|
import { FlashSaleModule } from './flash-sale/flash-sale.module'
|
||||||
|
import { InviteModule } from './invite/invite.module'
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -28,6 +30,8 @@ import { AdminModule } from './admin/admin.module'
|
|||||||
SchedulerModule,
|
SchedulerModule,
|
||||||
PaymentModule,
|
PaymentModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
|
FlashSaleModule,
|
||||||
|
InviteModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing'
|
import { Test, TestingModule } from '@nestjs/testing'
|
||||||
import { JwtService } from '@nestjs/jwt'
|
import { JwtService } from '@nestjs/jwt'
|
||||||
import { UnauthorizedException } from '@nestjs/common'
|
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 { AuthService, RANDOM_FN_TOKEN } from '../auth.service'
|
||||||
import { WechatService } from '../wechat.service'
|
import { WechatService } from '../wechat.service'
|
||||||
import { PrismaService } from '../../prisma/prisma.service'
|
import { PrismaService } from '../../prisma/prisma.service'
|
||||||
|
import { InviteService } from '../../invite/invite.service'
|
||||||
|
|
||||||
// ─── Fixtures ────────────────────────────────────────────────────────────────
|
// ─── Fixtures ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -22,6 +24,7 @@ const mockUser = {
|
|||||||
nickname: TEST_NICKNAME,
|
nickname: TEST_NICKNAME,
|
||||||
avatarUrl: null,
|
avatarUrl: null,
|
||||||
role: UserRole.MEMBER,
|
role: UserRole.MEMBER,
|
||||||
|
adminBookingSubscriptionCount: 0,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
}
|
}
|
||||||
@@ -29,6 +32,9 @@ const mockUser = {
|
|||||||
// ─── Mocks ───────────────────────────────────────────────────────────────────
|
// ─── Mocks ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const mockPrismaService = {
|
const mockPrismaService = {
|
||||||
|
membership: {
|
||||||
|
count: jest.fn(),
|
||||||
|
},
|
||||||
user: {
|
user: {
|
||||||
findUnique: jest.fn(),
|
findUnique: jest.fn(),
|
||||||
findUniqueOrThrow: jest.fn(),
|
findUniqueOrThrow: jest.fn(),
|
||||||
@@ -46,6 +52,14 @@ const mockJwtService = {
|
|||||||
sign: jest.fn(),
|
sign: jest.fn(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mockInviteService = {
|
||||||
|
bindInviterToUser: jest.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockConfigService = {
|
||||||
|
get: jest.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('AuthService', () => {
|
describe('AuthService', () => {
|
||||||
@@ -58,6 +72,8 @@ describe('AuthService', () => {
|
|||||||
{ provide: PrismaService, useValue: mockPrismaService },
|
{ provide: PrismaService, useValue: mockPrismaService },
|
||||||
{ provide: WechatService, useValue: mockWechatService },
|
{ provide: WechatService, useValue: mockWechatService },
|
||||||
{ provide: JwtService, useValue: mockJwtService },
|
{ provide: JwtService, useValue: mockJwtService },
|
||||||
|
{ provide: InviteService, useValue: mockInviteService },
|
||||||
|
{ provide: ConfigService, useValue: mockConfigService },
|
||||||
{ provide: RANDOM_FN_TOKEN, useValue: () => 0 }, // deterministic nickname
|
{ provide: RANDOM_FN_TOKEN, useValue: () => 0 }, // deterministic nickname
|
||||||
],
|
],
|
||||||
}).compile()
|
}).compile()
|
||||||
@@ -66,6 +82,8 @@ describe('AuthService', () => {
|
|||||||
|
|
||||||
jest.clearAllMocks()
|
jest.clearAllMocks()
|
||||||
mockJwtService.sign.mockReturnValue(JWT_TOKEN)
|
mockJwtService.sign.mockReturnValue(JWT_TOKEN)
|
||||||
|
mockPrismaService.membership.count.mockResolvedValue(0)
|
||||||
|
mockConfigService.get.mockReturnValue('tmpl-booking-confirmed')
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── login ──────────────────────────────────────────────────────────────────
|
// ── login ──────────────────────────────────────────────────────────────────
|
||||||
@@ -91,9 +109,30 @@ describe('AuthService', () => {
|
|||||||
where: { openid: OPENID },
|
where: { openid: OPENID },
|
||||||
})
|
})
|
||||||
expect(mockPrismaService.user.create).toHaveBeenCalledWith({
|
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 () => {
|
it('creates user with unionid when present', async () => {
|
||||||
@@ -109,7 +148,7 @@ describe('AuthService', () => {
|
|||||||
await authService.login(loginCode)
|
await authService.login(loginCode)
|
||||||
|
|
||||||
expect(mockPrismaService.user.create).toHaveBeenCalledWith({
|
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 },
|
where: { openid: OPENID },
|
||||||
})
|
})
|
||||||
expect(mockPrismaService.user.create).not.toHaveBeenCalled()
|
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 () => {
|
it('returns a valid JWT token', async () => {
|
||||||
@@ -142,10 +186,35 @@ describe('AuthService', () => {
|
|||||||
|
|
||||||
const result = await authService.login(loginCode)
|
const result = await authService.login(loginCode)
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual(expect.objectContaining({
|
||||||
token: JWT_TOKEN,
|
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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
Controller,
|
|
||||||
Post,
|
|
||||||
Body,
|
Body,
|
||||||
UseGuards,
|
Controller,
|
||||||
Request,
|
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
|
Post,
|
||||||
|
Request,
|
||||||
|
UseGuards,
|
||||||
} from '@nestjs/common'
|
} from '@nestjs/common'
|
||||||
|
import type { UserProfileResponse } from '@mp-pilates/shared'
|
||||||
import { AuthService } from './auth.service'
|
import { AuthService } from './auth.service'
|
||||||
import { LoginDto } from './dto/login.dto'
|
import { LoginDto } from './dto/login.dto'
|
||||||
import { BindPhoneDto } from './dto/bind-phone.dto'
|
import { BindPhoneDto } from './dto/bind-phone.dto'
|
||||||
@@ -24,11 +25,12 @@ export class AuthController {
|
|||||||
|
|
||||||
@Post('login')
|
@Post('login')
|
||||||
@HttpCode(HttpStatus.OK)
|
@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(
|
return this.authService.login(
|
||||||
loginDto.code,
|
loginDto.code,
|
||||||
loginDto.nickname,
|
loginDto.nickname,
|
||||||
loginDto.avatarUrl,
|
loginDto.avatarUrl,
|
||||||
|
loginDto.inviterId,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,21 @@ import { Module } from '@nestjs/common'
|
|||||||
import { PassportModule } from '@nestjs/passport'
|
import { PassportModule } from '@nestjs/passport'
|
||||||
import { JwtModule } from '@nestjs/jwt'
|
import { JwtModule } from '@nestjs/jwt'
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config'
|
import { ConfigModule, ConfigService } from '@nestjs/config'
|
||||||
|
import { MembershipModule } from '../membership/membership.module'
|
||||||
import { AuthService, RANDOM_FN_TOKEN } from './auth.service'
|
import { AuthService, RANDOM_FN_TOKEN } from './auth.service'
|
||||||
import { AuthController } from './auth.controller'
|
import { AuthController } from './auth.controller'
|
||||||
import { WechatService } from './wechat.service'
|
import { WechatService } from './wechat.service'
|
||||||
import { JwtStrategy } from './jwt.strategy'
|
import { JwtStrategy } from './jwt.strategy'
|
||||||
import { JwtAuthGuard } from './jwt-auth.guard'
|
import { JwtAuthGuard } from './jwt-auth.guard'
|
||||||
import { RolesGuard } from './roles.guard'
|
import { RolesGuard } from './roles.guard'
|
||||||
|
import { InviteModule } from '../invite/invite.module'
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
PassportModule,
|
PassportModule,
|
||||||
|
InviteModule,
|
||||||
|
ConfigModule,
|
||||||
|
MembershipModule,
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'
|
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'
|
||||||
import { JwtService } from '@nestjs/jwt'
|
import { JwtService } from '@nestjs/jwt'
|
||||||
import { User } from '@prisma/client'
|
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 { PrismaService } from '../prisma/prisma.service'
|
||||||
import { WechatService } from './wechat.service'
|
import { WechatService } from './wechat.service'
|
||||||
|
import { InviteService } from '../invite/invite.service'
|
||||||
|
|
||||||
export interface LoginResult {
|
export interface LoginResult {
|
||||||
token: string
|
token: string
|
||||||
user: User
|
user: UserProfileResponse
|
||||||
|
isNewUser: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JwtPayload {
|
export interface JwtPayload {
|
||||||
@@ -54,13 +64,59 @@ export class AuthService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly wechatService: WechatService,
|
private readonly wechatService: WechatService,
|
||||||
|
private readonly inviteService: InviteService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
@Inject(RANDOM_FN_TOKEN) private readonly randomFn: () => number = Math.random,
|
@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(
|
async login(
|
||||||
code: string,
|
code: string,
|
||||||
nickname?: string,
|
nickname?: string,
|
||||||
avatarUrl?: string,
|
avatarUrl?: string,
|
||||||
|
inviterId?: string,
|
||||||
): Promise<LoginResult> {
|
): Promise<LoginResult> {
|
||||||
const { openid, unionid, sessionKey } =
|
const { openid, unionid, sessionKey } =
|
||||||
await this.wechatService.code2Session(code)
|
await this.wechatService.code2Session(code)
|
||||||
@@ -69,6 +125,8 @@ export class AuthService {
|
|||||||
where: { openid },
|
where: { openid },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const isNewUser = existingUser === null
|
||||||
|
|
||||||
const user =
|
const user =
|
||||||
existingUser ??
|
existingUser ??
|
||||||
(await this.prisma.user.create({
|
(await this.prisma.user.create({
|
||||||
@@ -77,6 +135,7 @@ export class AuthService {
|
|||||||
...(unionid !== undefined && { unionid }),
|
...(unionid !== undefined && { unionid }),
|
||||||
nickname: nickname || generateDefaultNickname(this.randomFn),
|
nickname: nickname || generateDefaultNickname(this.randomFn),
|
||||||
...(avatarUrl && { avatarUrl }),
|
...(avatarUrl && { avatarUrl }),
|
||||||
|
adminBookingSubscriptionCount: 0,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -89,15 +148,19 @@ export class AuthService {
|
|||||||
sessionKeyStore.set(updated.id, sessionKey)
|
sessionKeyStore.set(updated.id, sessionKey)
|
||||||
const payload: JwtPayload = { sub: updated.id, role: updated.role as UserRole }
|
const payload: JwtPayload = { sub: updated.id, role: updated.role as UserRole }
|
||||||
const token = this.jwtService.sign(payload)
|
const token = this.jwtService.sign(payload)
|
||||||
return { token, user: updated }
|
return { token, user: await this.mapLoginUser(updated), isNewUser: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionKeyStore.set(user.id, sessionKey)
|
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 payload: JwtPayload = { sub: user.id, role: user.role as UserRole }
|
||||||
const token = this.jwtService.sign(payload)
|
const token = this.jwtService.sign(payload)
|
||||||
|
|
||||||
return { token, user }
|
return { token, user: await this.mapLoginUser(user), isNewUser }
|
||||||
}
|
}
|
||||||
|
|
||||||
async bindPhone(
|
async bindPhone(
|
||||||
|
|||||||
@@ -12,4 +12,8 @@ export class LoginDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
inviterId?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import {
|
|||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common'
|
} 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 { BookingService } from '../booking.service'
|
||||||
import { PrismaService } from '../../prisma/prisma.service'
|
import { PrismaService } from '../../prisma/prisma.service'
|
||||||
import { MembershipService } from '../../membership/membership.service'
|
import { MembershipService } from '../../membership/membership.service'
|
||||||
import { StudioService } from '../../studio/studio.service'
|
import { StudioService } from '../../studio/studio.service'
|
||||||
|
import { SubscriptionMessageService } from '../../user/subscription-message.service'
|
||||||
|
import { InviteService } from '../../invite/invite.service'
|
||||||
|
|
||||||
// ─── Fixtures ──────────────────────────────────────────────────────────────
|
// ─── Fixtures ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -138,6 +140,9 @@ function buildTxMock(overrides: Record<string, unknown> = {}) {
|
|||||||
findUnique: jest.fn(),
|
findUnique: jest.fn(),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
},
|
},
|
||||||
|
bookingStatusHistory: {
|
||||||
|
create: jest.fn(),
|
||||||
|
},
|
||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,6 +153,8 @@ describe('BookingService', () => {
|
|||||||
let service: BookingService
|
let service: BookingService
|
||||||
let prisma: jest.Mocked<PrismaService>
|
let prisma: jest.Mocked<PrismaService>
|
||||||
let studioService: jest.Mocked<StudioService>
|
let studioService: jest.Mocked<StudioService>
|
||||||
|
let subscriptionMessageService: { sendBookingConfirmedMessage: jest.Mock; sendAdminBookingCreatedMessage: jest.Mock }
|
||||||
|
let inviteService: { recordQualifiedTrialBooking: jest.Mock }
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
@@ -166,12 +173,17 @@ describe('BookingService', () => {
|
|||||||
},
|
},
|
||||||
timeSlot: {
|
timeSlot: {
|
||||||
findUnique: jest.fn(),
|
findUnique: jest.fn(),
|
||||||
|
findMany: jest.fn(),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
},
|
},
|
||||||
membership: {
|
membership: {
|
||||||
findUnique: jest.fn(),
|
findUnique: jest.fn(),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
},
|
},
|
||||||
|
user: {
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
findMany: jest.fn(),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -188,38 +200,141 @@ describe('BookingService', () => {
|
|||||||
getInfo: jest.fn(),
|
getInfo: jest.fn(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: SubscriptionMessageService,
|
||||||
|
useValue: {
|
||||||
|
sendBookingConfirmedMessage: jest.fn(),
|
||||||
|
sendAdminBookingCreatedMessage: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: InviteService,
|
||||||
|
useValue: {
|
||||||
|
recordQualifiedTrialBooking: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}).compile()
|
}).compile()
|
||||||
|
|
||||||
service = module.get<BookingService>(BookingService)
|
service = module.get<BookingService>(BookingService)
|
||||||
prisma = module.get(PrismaService) as jest.Mocked<PrismaService>
|
prisma = module.get(PrismaService) as jest.Mocked<PrismaService>
|
||||||
studioService = module.get(StudioService) as jest.Mocked<StudioService>
|
studioService = module.get(StudioService) as jest.Mocked<StudioService>
|
||||||
|
subscriptionMessageService = module.get(SubscriptionMessageService)
|
||||||
|
inviteService = module.get(InviteService)
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => jest.clearAllMocks())
|
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 ────────────────────────────────────────────────────────
|
// ─── createBooking ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('createBooking', () => {
|
describe('createBooking', () => {
|
||||||
const dto = { timeSlotId: MOCK_SLOT_ID, membershipId: MOCK_MEMBERSHIP_ID }
|
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()
|
const tx = buildTxMock()
|
||||||
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
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.membership.findUnique.mockResolvedValue(mockActiveMembership)
|
||||||
tx.booking.create.mockResolvedValue(mockConfirmedBooking)
|
tx.booking.create.mockResolvedValue({
|
||||||
tx.timeSlot.update.mockResolvedValue({ ...mockOpenSlot, bookedCount: 1 })
|
...mockConfirmedBooking,
|
||||||
tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 4 })
|
status: BookingStatus.PENDING_CONFIRMATION,
|
||||||
|
})
|
||||||
|
|
||||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||||
|
|
||||||
// Mock the re-fetch after transaction
|
// Mock the re-fetch after transaction
|
||||||
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
|
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
|
||||||
...mockConfirmedBooking,
|
...mockConfirmedBooking,
|
||||||
|
status: BookingStatus.PENDING_CONFIRMATION,
|
||||||
timeSlot: mockOpenSlot,
|
timeSlot: mockOpenSlot,
|
||||||
membership: mockActiveMembership,
|
membership: mockActiveMembership,
|
||||||
})
|
})
|
||||||
|
;(prisma.user.findMany as jest.Mock).mockResolvedValue([])
|
||||||
|
|
||||||
const result = await service.createBooking(MOCK_USER_ID, dto)
|
const result = await service.createBooking(MOCK_USER_ID, dto)
|
||||||
|
|
||||||
@@ -229,55 +344,46 @@ describe('BookingService', () => {
|
|||||||
userId: MOCK_USER_ID,
|
userId: MOCK_USER_ID,
|
||||||
timeSlotId: MOCK_SLOT_ID,
|
timeSlotId: MOCK_SLOT_ID,
|
||||||
membershipId: MOCK_MEMBERSHIP_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).not.toHaveBeenCalled()
|
||||||
expect(tx.timeSlot.update).toHaveBeenCalledWith(
|
expect(tx.membership.update).not.toHaveBeenCalled()
|
||||||
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(result).toBeDefined()
|
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 nearFullSlot = { ...mockOpenSlot, bookedCount: 4, capacity: 5 }
|
||||||
|
|
||||||
const tx = buildTxMock()
|
const tx = buildTxMock()
|
||||||
tx.timeSlot.findUnique.mockResolvedValue(nearFullSlot)
|
tx.timeSlot.findUnique.mockResolvedValue(nearFullSlot)
|
||||||
tx.booking.findFirst.mockResolvedValue(null)
|
tx.booking.findUnique.mockResolvedValue(null)
|
||||||
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
|
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
|
||||||
tx.booking.create.mockResolvedValue(mockConfirmedBooking)
|
tx.booking.create.mockResolvedValue({
|
||||||
tx.timeSlot.update.mockResolvedValue({ ...nearFullSlot, bookedCount: 5, status: TimeSlotStatus.FULL })
|
...mockConfirmedBooking,
|
||||||
tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 4 })
|
status: BookingStatus.PENDING_CONFIRMATION,
|
||||||
|
})
|
||||||
|
|
||||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||||
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
|
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
|
||||||
...mockConfirmedBooking,
|
...mockConfirmedBooking,
|
||||||
timeSlot: { ...nearFullSlot, status: TimeSlotStatus.FULL },
|
status: BookingStatus.PENDING_CONFIRMATION,
|
||||||
|
timeSlot: nearFullSlot,
|
||||||
membership: mockActiveMembership,
|
membership: mockActiveMembership,
|
||||||
})
|
})
|
||||||
|
;(prisma.user.findMany as jest.Mock).mockResolvedValue([])
|
||||||
|
|
||||||
await service.createBooking(MOCK_USER_ID, dto)
|
await service.createBooking(MOCK_USER_ID, dto)
|
||||||
|
|
||||||
// bookedCount 4+1 = 5 = capacity → FULL
|
expect(tx.bookingStatusHistory.create).toHaveBeenCalledWith(
|
||||||
expect(tx.timeSlot.update).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
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()
|
const tx = buildTxMock()
|
||||||
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
||||||
tx.booking.findFirst.mockResolvedValue(null)
|
tx.booking.findUnique.mockResolvedValue(null)
|
||||||
tx.membership.findUnique.mockResolvedValue(mockDurationMembership)
|
tx.membership.findUnique.mockResolvedValue(mockDurationMembership)
|
||||||
tx.booking.create.mockResolvedValue({ ...mockConfirmedBooking, membershipId: mockDurationMembership.id })
|
tx.booking.create.mockResolvedValue({ ...mockConfirmedBooking, membershipId: mockDurationMembership.id })
|
||||||
tx.timeSlot.update.mockResolvedValue({ ...mockOpenSlot, bookedCount: 1 })
|
tx.timeSlot.update.mockResolvedValue({ ...mockOpenSlot, bookedCount: 1 })
|
||||||
@@ -299,6 +405,7 @@ describe('BookingService', () => {
|
|||||||
timeSlot: mockOpenSlot,
|
timeSlot: mockOpenSlot,
|
||||||
membership: mockDurationMembership,
|
membership: mockDurationMembership,
|
||||||
})
|
})
|
||||||
|
;(prisma.user.findMany as jest.Mock).mockResolvedValue([])
|
||||||
|
|
||||||
await service.createBooking(MOCK_USER_ID, durationDto)
|
await service.createBooking(MOCK_USER_ID, durationDto)
|
||||||
|
|
||||||
@@ -306,34 +413,78 @@ describe('BookingService', () => {
|
|||||||
expect(tx.membership.update).not.toHaveBeenCalled()
|
expect(tx.membership.update).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('marks membership as USED_UP when remainingTimes hits 0', async () => {
|
it('allows time-based membership with zero remaining times and leaves deduction to admin confirmation', async () => {
|
||||||
const lastTimeMembership = { ...mockActiveMembership, remainingTimes: 1 }
|
|
||||||
|
|
||||||
const tx = buildTxMock()
|
const tx = buildTxMock()
|
||||||
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
||||||
tx.booking.findFirst.mockResolvedValue(null)
|
tx.booking.findUnique.mockResolvedValue(null)
|
||||||
tx.membership.findUnique.mockResolvedValue(lastTimeMembership)
|
tx.membership.findUnique.mockResolvedValue(mockMembershipNoTimes)
|
||||||
tx.booking.create.mockResolvedValue(mockConfirmedBooking)
|
tx.booking.create.mockResolvedValue({
|
||||||
tx.timeSlot.update.mockResolvedValue({ ...mockOpenSlot, bookedCount: 1 })
|
...mockConfirmedBooking,
|
||||||
tx.membership.update.mockResolvedValue({ ...lastTimeMembership, remainingTimes: 0, status: MembershipStatus.USED_UP })
|
membershipId: mockMembershipNoTimes.id,
|
||||||
|
status: BookingStatus.PENDING_CONFIRMATION,
|
||||||
|
})
|
||||||
|
|
||||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||||
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
|
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
|
||||||
...mockConfirmedBooking,
|
...mockConfirmedBooking,
|
||||||
|
membershipId: mockMembershipNoTimes.id,
|
||||||
|
status: BookingStatus.PENDING_CONFIRMATION,
|
||||||
timeSlot: mockOpenSlot,
|
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)
|
await service.createBooking(MOCK_USER_ID, dto)
|
||||||
|
|
||||||
expect(tx.membership.update).toHaveBeenCalledWith(
|
expect(tx.membership.update).not.toHaveBeenCalled()
|
||||||
expect.objectContaining({
|
})
|
||||||
data: expect.objectContaining({
|
|
||||||
remainingTimes: 0,
|
it('sends admin booking created subscription message to admins with remaining count', async () => {
|
||||||
status: MembershipStatus.USED_UP,
|
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 () => {
|
it('throws BadRequestException when slot is FULL', async () => {
|
||||||
@@ -352,7 +503,7 @@ describe('BookingService', () => {
|
|||||||
it('throws ConflictException on duplicate booking (same user + slot)', async () => {
|
it('throws ConflictException on duplicate booking (same user + slot)', async () => {
|
||||||
const tx = buildTxMock()
|
const tx = buildTxMock()
|
||||||
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
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))
|
;(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 () => {
|
it('throws BadRequestException when membership is not ACTIVE (expired status)', async () => {
|
||||||
const tx = buildTxMock()
|
const tx = buildTxMock()
|
||||||
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
||||||
tx.booking.findFirst.mockResolvedValue(null)
|
tx.booking.findUnique.mockResolvedValue(null)
|
||||||
tx.membership.findUnique.mockResolvedValue(mockExpiredMembership)
|
tx.membership.findUnique.mockResolvedValue(mockExpiredMembership)
|
||||||
|
|
||||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
;(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 () => {
|
it('throws NotFoundException when timeSlot does not exist', async () => {
|
||||||
const tx = buildTxMock()
|
const tx = buildTxMock()
|
||||||
tx.timeSlot.findUnique.mockResolvedValue(null)
|
tx.timeSlot.findUnique.mockResolvedValue(null)
|
||||||
@@ -404,7 +541,7 @@ describe('BookingService', () => {
|
|||||||
|
|
||||||
const tx = buildTxMock()
|
const tx = buildTxMock()
|
||||||
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
||||||
tx.booking.findFirst.mockResolvedValue(null)
|
tx.booking.findUnique.mockResolvedValue(null)
|
||||||
tx.membership.findUnique.mockResolvedValue(otherUserMembership)
|
tx.membership.findUnique.mockResolvedValue(otherUserMembership)
|
||||||
|
|
||||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||||
@@ -413,6 +550,65 @@ describe('BookingService', () => {
|
|||||||
ForbiddenException,
|
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 ────────────────────────────────────────────────────────
|
// ─── cancelBooking ────────────────────────────────────────────────────────
|
||||||
@@ -662,7 +858,7 @@ describe('BookingService', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
where: expect.objectContaining({
|
where: expect.objectContaining({
|
||||||
userId: MOCK_USER_ID,
|
userId: MOCK_USER_ID,
|
||||||
status: BookingStatus.CONFIRMED,
|
status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] },
|
||||||
}),
|
}),
|
||||||
orderBy: [
|
orderBy: [
|
||||||
{ timeSlot: { date: 'asc' } },
|
{ 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,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
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')
|
@Put('booking/:id/confirm')
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
@Roles(UserRole.ADMIN)
|
@Roles(UserRole.ADMIN)
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import { BookingController } from './booking.controller'
|
|||||||
import { BookingService } from './booking.service'
|
import { BookingService } from './booking.service'
|
||||||
import { MembershipModule } from '../membership/membership.module'
|
import { MembershipModule } from '../membership/membership.module'
|
||||||
import { StudioModule } from '../studio/studio.module'
|
import { StudioModule } from '../studio/studio.module'
|
||||||
|
import { UserModule } from '../user/user.module'
|
||||||
|
import { InviteModule } from '../invite/invite.module'
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [MembershipModule, StudioModule],
|
imports: [MembershipModule, StudioModule, UserModule, InviteModule],
|
||||||
controllers: [BookingController],
|
controllers: [BookingController],
|
||||||
providers: [BookingService],
|
providers: [BookingService],
|
||||||
exports: [BookingService],
|
exports: [BookingService],
|
||||||
|
|||||||
@@ -6,11 +6,19 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common'
|
} from '@nestjs/common'
|
||||||
import { Booking, Membership, TimeSlot, BookingStatusHistory } from '@prisma/client'
|
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 { PrismaService } from '../prisma/prisma.service'
|
||||||
import { MembershipService } from '../membership/membership.service'
|
import { MembershipService } from '../membership/membership.service'
|
||||||
import { StudioService } from '../studio/studio.service'
|
import { StudioService } from '../studio/studio.service'
|
||||||
|
import { SubscriptionMessageService } from '../user/subscription-message.service'
|
||||||
import { CreateBookingDto } from './dto/create-booking.dto'
|
import { CreateBookingDto } from './dto/create-booking.dto'
|
||||||
|
import { InviteService } from '../invite/invite.service'
|
||||||
|
|
||||||
// ─── Types ─────────────────────────────────────────────────────────────────
|
// ─── Types ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -48,6 +56,8 @@ export class BookingService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly membershipService: MembershipService,
|
private readonly membershipService: MembershipService,
|
||||||
private readonly studioService: StudioService,
|
private readonly studioService: StudioService,
|
||||||
|
private readonly subscriptionMessageService: SubscriptionMessageService,
|
||||||
|
private readonly inviteService: InviteService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ─── Create Booking ──────────────────────────────────────────────────────
|
// ─── 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
|
// 2. Find existing booking record for this user + slot.
|
||||||
const existing = await tx.booking.findFirst({
|
// 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: {
|
where: {
|
||||||
userId,
|
userId_timeSlotId: {
|
||||||
timeSlotId: dto.timeSlotId,
|
userId,
|
||||||
status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] },
|
timeSlotId: dto.timeSlotId,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if (existing) {
|
if (
|
||||||
|
existing &&
|
||||||
|
(existing.status === 'PENDING_CONFIRMATION' || existing.status === 'CONFIRMED')
|
||||||
|
) {
|
||||||
throw new ConflictException('You have already booked this time slot')
|
throw new ConflictException('You have already booked this time slot')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,24 +128,40 @@ export class BookingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Create booking with PENDING_CONFIRMATION status
|
// 5. Create booking or revive a previously cancelled booking.
|
||||||
const newBooking = await tx.booking.create({
|
const newBooking = existing?.status === BookingStatus.CANCELLED
|
||||||
data: {
|
? await tx.booking.update({
|
||||||
userId,
|
where: { id: existing.id },
|
||||||
timeSlotId: dto.timeSlotId,
|
data: {
|
||||||
membershipId: dto.membershipId,
|
membershipId: dto.membershipId,
|
||||||
status: BookingStatus.PENDING_CONFIRMATION,
|
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({
|
await tx.bookingStatusHistory.create({
|
||||||
data: {
|
data: {
|
||||||
bookingId: newBooking.id,
|
bookingId: newBooking.id,
|
||||||
fromStatus: null,
|
fromStatus: existing?.status === BookingStatus.CANCELLED
|
||||||
|
? BookingStatus.CANCELLED
|
||||||
|
: null,
|
||||||
toStatus: BookingStatus.PENDING_CONFIRMATION,
|
toStatus: BookingStatus.PENDING_CONFIRMATION,
|
||||||
operatorId: userId,
|
operatorId: userId,
|
||||||
remark: '学员发起预约',
|
remark: existing?.status === BookingStatus.CANCELLED
|
||||||
|
? '学员重新发起预约'
|
||||||
|
: '学员发起预约',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -138,7 +169,9 @@ export class BookingService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Re-fetch with relations after transaction
|
// 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) ─────────────────────────────────────────────
|
// ─── Confirm Booking (Admin) ─────────────────────────────────────────────
|
||||||
@@ -235,7 +268,9 @@ export class BookingService {
|
|||||||
return updated
|
return updated
|
||||||
})
|
})
|
||||||
|
|
||||||
return this.fetchBookingWithRelations(booking.id)
|
const confirmedBooking = await this.fetchBookingWithRelations(booking.id)
|
||||||
|
await this.trySendBookingConfirmedSubscriptionMessage(confirmedBooking)
|
||||||
|
return confirmedBooking
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Complete / NoShow Booking (Admin) ──────────────────────────────────
|
// ─── Complete / NoShow Booking (Admin) ──────────────────────────────────
|
||||||
@@ -303,7 +338,11 @@ export class BookingService {
|
|||||||
return updated
|
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 ──────────────────────────────────────────────────────
|
// ─── 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 Helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
private async fetchBookingWithRelations(bookingId: string): Promise<BookingWithRelations> {
|
private async fetchBookingWithRelations(bookingId: string): Promise<BookingWithRelations> {
|
||||||
@@ -566,4 +671,90 @@ export class BookingService {
|
|||||||
|
|
||||||
return { ...booking } as BookingWithRelations
|
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 '学员'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from '@nestjs/common'
|
} from '@nestjs/common'
|
||||||
import type { Request, Response } from 'express'
|
import type { Request, Response } from 'express'
|
||||||
import type { ApiResponse } from '@mp-pilates/shared'
|
import type { ApiResponse } from '@mp-pilates/shared'
|
||||||
|
import { formatRequestExtras } from '../utils/request-log'
|
||||||
|
|
||||||
@Catch()
|
@Catch()
|
||||||
export class ApiExceptionFilter implements ExceptionFilter {
|
export class ApiExceptionFilter implements ExceptionFilter {
|
||||||
@@ -28,15 +29,16 @@ export class ApiExceptionFilter implements ExceptionFilter {
|
|||||||
? this.extractMessage(exception)
|
? this.extractMessage(exception)
|
||||||
: '服务器内部错误'
|
: '服务器内部错误'
|
||||||
|
|
||||||
// Log all server errors (5xx) with full stack; log 4xx at warn level
|
const extras = formatRequestExtras(request)
|
||||||
|
|
||||||
if (status >= 500) {
|
if (status >= 500) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`${request.method} ${request.originalUrl} → ${String(status)} ${message}`,
|
`${request.method} ${request.originalUrl} → ${String(status)} ${message}${extras}`,
|
||||||
exception instanceof Error ? exception.stack : undefined,
|
exception instanceof Error ? exception.stack : undefined,
|
||||||
)
|
)
|
||||||
} else if (status >= 400) {
|
} else if (status >= 400) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`${request.method} ${request.originalUrl} → ${String(status)} ${message}`,
|
`${request.method} ${request.originalUrl} → ${String(status)} ${message}${extras}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,28 +7,7 @@ import {
|
|||||||
} from '@nestjs/common'
|
} from '@nestjs/common'
|
||||||
import { Observable, tap } from 'rxjs'
|
import { Observable, tap } from 'rxjs'
|
||||||
import type { Request, Response } from 'express'
|
import type { Request, Response } from 'express'
|
||||||
|
import { formatRequestExtras } from '../utils/request-log'
|
||||||
/** 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],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LoggingInterceptor implements NestInterceptor {
|
export class LoggingInterceptor implements NestInterceptor {
|
||||||
@@ -44,9 +23,9 @@ export class LoggingInterceptor implements NestInterceptor {
|
|||||||
next: () => {
|
next: () => {
|
||||||
const res = context.switchToHttp().getResponse<Response>()
|
const res = context.switchToHttp().getResponse<Response>()
|
||||||
const duration = Date.now() - start
|
const duration = Date.now() - start
|
||||||
const bodyLog = this.formatBody(method, req.body as Record<string, unknown>)
|
const extras = formatRequestExtras(req)
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`${method} ${originalUrl} → ${String(res.statusCode)} (${String(duration)}ms)${bodyLog}`,
|
`${method} ${originalUrl} → ${String(res.statusCode)} (${String(duration)}ms)${extras}`,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
error: (err: unknown) => {
|
error: (err: unknown) => {
|
||||||
@@ -55,22 +34,12 @@ export class LoggingInterceptor implements NestInterceptor {
|
|||||||
err instanceof Object && 'getStatus' in err
|
err instanceof Object && 'getStatus' in err
|
||||||
? String((err as { getStatus: () => number }).getStatus())
|
? String((err as { getStatus: () => number }).getStatus())
|
||||||
: '500'
|
: '500'
|
||||||
const bodyLog = this.formatBody(method, req.body as Record<string, unknown>)
|
const extras = formatRequestExtras(req)
|
||||||
this.logger.error(
|
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)}`
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
60
packages/server/src/common/utils/request-log.ts
Normal file
60
packages/server/src/common/utils/request-log.ts
Normal 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(' ')}` : ''
|
||||||
|
}
|
||||||
35
packages/server/src/flash-sale/dto/create-flash-sale.dto.ts
Normal file
35
packages/server/src/flash-sale/dto/create-flash-sale.dto.ts
Normal 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
|
||||||
|
}
|
||||||
43
packages/server/src/flash-sale/dto/update-flash-sale.dto.ts
Normal file
43
packages/server/src/flash-sale/dto/update-flash-sale.dto.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
40
packages/server/src/flash-sale/flash-sale.controller.ts
Normal file
40
packages/server/src/flash-sale/flash-sale.controller.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
14
packages/server/src/flash-sale/flash-sale.module.ts
Normal file
14
packages/server/src/flash-sale/flash-sale.module.ts
Normal 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 {}
|
||||||
409
packages/server/src/flash-sale/flash-sale.service.ts
Normal file
409
packages/server/src/flash-sale/flash-sale.service.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
3
packages/server/src/invite/invite.constants.ts
Normal file
3
packages/server/src/invite/invite.constants.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export const INVITE_REWARD_REQUIRED_COUNT = 3
|
||||||
|
export const INVITE_REWARD_TIMES = 1
|
||||||
|
|
||||||
16
packages/server/src/invite/invite.controller.ts
Normal file
16
packages/server/src/invite/invite.controller.ts
Normal 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
Reference in New Issue
Block a user