Compare commits

...

49 Commits

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:12:53 +08:00
richarjiang
ba4e4bb301 perf: 优化首页效果 2026-04-06 16:50:28 +08:00
richarjiang
f8268cb6f6 perf: 完善新用户引导 2026-04-06 15:50:22 +08:00
richarjiang
66d47ec162 perf: 优化 UI 2026-04-06 11:15:10 +08:00
richarjiang
3a9982209f feat: 完善课程订阅 2026-04-06 08:38:05 +08:00
richarjiang
f71ff968ad perf: 更新依赖版本 2026-04-05 21:41:54 +08:00
richarjiang
c0e0d31ae7 feat: 优化主题配色 2026-04-05 21:35:30 +08:00
richarjiang
4633ceea8c perf: 完善订单管理 2026-04-05 21:03:18 +08:00
richarjiang
fdb13c32c2 perf: 修复我的约课列表不展示的问题 2026-04-05 18:39:34 +08:00
richarjiang
694330b7a6 feat: 接入微信支付 2026-04-05 18:23:23 +08:00
richarjiang
9eee4f6b87 perf: 支持微信支付接口 2026-04-05 14:09:36 +08:00
richarjiang
9811c9a13b perf: 优化页面 2026-04-05 13:25:54 +08:00
richarjiang
a85270efd4 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.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 12:53:03 +08:00
richarjiang
b6986ba30c feat(admin): implement full day-by-day schedule editor with live preview
## Features

### Admin Schedule Page (`packages/app/src/pages/admin/schedule.vue`)
- Interactive date-based slot editor for managing daily schedules
- Real-time slot editing: start/end times, capacity adjustments
- Slot deletion with conflict warnings when bookings exist
- Add new slots with modal dialog
- Live booking status display (booked count, people names)
- Publish/Save changes with sync feedback
- Revert unsaved changes with confirmation
- Skeleton loading states and empty state handling
- Responsive design with optimized mobile UX

### Backend Enhancements
- **New DTO** (`PublishDaySlotsDto`): Structured slot publishing with validation
  - Date string validation
  - Slot array with existing slot IDs for updates
  - Time and capacity validation per slot

- **Schedule Preview API** (`getSchedulePreview`):
  - Check for existing published slots
  - Fallback to active WeekTemplates for unpublished dates
  - Unified response format with isPublished flag

- **Publish Slots API** (`publishDaySlots`):
  - Atomic transaction for consistency
  - Update existing slots with new times/capacity
  - Create new slots from template data
  - Delete unpublished slots or set to CLOSED if bookings exist
  - Prevent capacity reduction below existing bookings
  - Returns all published slots for feedback

### State Management
- Enhanced admin store with schedule state
- Support for pending/unsaved slot changes
- Optimistic UI updates with server sync

### Documentation
- Comprehensive scheduling system architecture docs
- Quick reference for admin workflows
- Flow diagrams and state transitions
- Implementation guide for future maintenance

## Breaking Changes
None

## Testing Recommendations
- Create slots for future dates via schedule editor
- Verify booking prevention for locked/full slots
- Test capacity adjustments with existing bookings
- Confirm template-based schedule generation
- Verify transaction rollback on publish failures

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 12:18:49 +08:00
149 changed files with 24556 additions and 3814 deletions

View File

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

37
AGENTS.md Normal file
View File

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

147
CLAUDE.md
View File

@@ -1,99 +1,88 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
本文档为 Claude Code (claude.ai/code) 在本项目中工作时提供指导。
## 项目概述
普拉提工作室预约与会员管理的微信小程序。TypeScript monorepo包含三个包:
这是一个普拉提预约微信小程序项目,后端采用 NestJS 框架。项目使用 pnpm monorepo 结构,包含 3 个包:
- **packages/server** — NestJS 后端REST API、Prisma ORM、PostgreSQL
- **packages/app** — Vue 3 + Pinia 前端,基于 Uni-app目标平台 mp-weixin
- **packages/shared** — 前后端共用的 TypeScript 类型、枚举常量
- **packages/app** - Vue 3 + uni-app微信小程序前端
- **packages/server** - NestJS后端 API 服务
- **packages/shared** - TypeScript 类型定义、枚举常量(前后端共用)
## 常用命令
### 开发
```bash
pnpm dev:server # NestJS watch 模式 (localhost:3000)
pnpm dev:app # 微信小程序开发服务器
pnpm build:shared # 必须先构建 shared再构建 server/app
```
# 开发
pnpm dev:server # 启动 NestJS 后端(热重载)
pnpm dev:app # 构建 uni-app 为微信小程序
### 测试(仅 server
```bash
# 构建
pnpm build:shared # 编译共享类型
pnpm build:server # 构建 NestJS 后端
pnpm build:app # 构建微信小程序
# 测试与代码检查
pnpm test # 运行所有测试(仅 server
pnpm lint # 运行 ESLint仅 server
# 数据库相关(位于 packages/server 目录)
cd packages/server
pnpm test # 运行全部测试
pnpm test -- auth.service.spec # 运行单个测试文件
pnpm test:watch # watch 模式
pnpm test:cov # 覆盖率报告
pnpm prisma:generate # 生成 Prisma 客户端
pnpm prisma:migrate # 执行数据库迁移
pnpm prisma:seed # 填充测试数据
pnpm test:watch # 监听模式运行测试
# 部署
pnpm deploy:server # 部署后端到生产环境
```
Jest 配置内联在 `packages/server/package.json`。测试文件位于 `__tests__/` 子目录(如 `src/auth/__tests__/auth.service.spec.ts`),匹配模式:`*.spec.ts`
## 架构说明
### 前端 (packages/app)
- 基于 Vue 3 + uni-app 框架,主攻微信小程序平台
- 页面目录:`src/pages/`(包含 home、booking、card、profile、admin 等模块)
- 组件目录:`src/components/`
- 状态管理Pinia
- 样式SCSS
### 后端 (packages/server)
- 框架NestJS + Prisma ORM
- 核心模块auth认证、user用户、booking预约、membership会员卡、payment支付、studio场馆、time-slot时段、scheduler定时任务、admin管理
- 认证JWT + 微信登录
- 定时任务:@nestjs/schedule
- 数据库SQLite开发/ MySQL生产
### 共享包 (packages/shared)
- TypeScript 接口和类型定义
- 枚举值定义
- 前后端共用的 DTO 类型
### API 结构
- 所有接口统一前缀:`/api`
- RESTful 风格接口
- 全局拦截器:日志记录、响应包装
- 全局过滤器:异常处理
### 数据库
```bash
cd packages/server
pnpm prisma:generate # schema 变更后重新生成 Prisma Client
pnpm prisma:migrate # 运行迁移(交互式)
pnpm prisma:seed # 填充种子数据
```
- Prisma schema 位于 `packages/server/prisma/schema.prisma`
- 核心数据模型User、Studio、TimeSlot、Booking、Membership、CardType、Order
- 注意查询会员列表时booking 统计通过 `groupBy` 批量获取,避免 N+1 查询
### 代码检查
```bash
pnpm lint # 所有包的 ESLint 检查
```
### 卡类型枚举
- `CardTypeCategory` (TIMES/DURATION/TRIAL) 定义在 `packages/shared/src/enums.ts`
- 会员管理筛选使用特殊值 `NONE` 表示无卡/无有效会员(不在枚举中)
- 前端选项硬编码在 `src/pages/admin/members.vue``cardTypeOptions`,需与枚举保持同步
## 架构
### 管理后台 API 模式
- `/admin/members` 支持 `page`, `limit`, `search`, `cardType` 参数
- `cardType=NONE` → 无有效会员的用户;其他值对应 `CardTypeCategory`
- 预约统计total/completed/cancelled通过 `groupBy` 批量查询
### 数据流
```
微信小程序 → Uni-app (Vue 3) → REST API (NestJS) → Prisma → PostgreSQL
↕ ↕
Pinia stores @nestjs/schedule (定时任务)
```
### 筛选组件模式
- picker 筛选使用 300ms debounce 再触发加载,避免频繁请求
- 列表分页使用 `onReachBottom` + `hasMore` 标志位实现无限滚动
### 后端模块结构
每个功能是一个 NestJS 模块,遵循 controller → service → Prisma 模式。核心模块:
- **auth** — 微信 OAuth 登录code2Session、JWT 令牌、手机号绑定
- **booking** — 创建/取消预约,含会员卡验证和容量检查
- **time-slot** — 课程时段管理;`SlotGeneratorService` 根据 `WeekTemplate` 自动生成
- **membership** — 基于卡的会员制TIMES 次卡、DURATION 时效卡、TRIAL 体验卡)
- **payment** — 微信支付集成,用于购卡
- **scheduler** — 定时任务02:00 自动生成时段02:30 清理过期时段
### 前端结构
- **pages/** — 按路由组织的页面home、booking、card、profile、admin
- **stores/** — Pinia 状态管理user、booking、studio、admin
- **utils/request.ts** — 封装 `uni.request` 的 HTTP 客户端,自动携带 JWT
- **utils/auth.ts** — 微信登录流程uni.login → 服务端 /auth/login → 存储 token
### Shared 包
所有 API 类型、DTO、枚举和业务常量定义在 `packages/shared/src/`,前后端通过 `@mp-pilates/shared` 引用。路径别名配置在 `tsconfig.base.json` 和 Jest 的 `moduleNameMapper` 中。
### 数据库 Schema
Prisma schema 位于 `packages/server/prisma/schema.prisma`,关键约定:
- Model 用 PascalCase表名用 snake_case`@@map`
- 字段用 camelCase列名用 snake_case`@map`
- 所有 ID 为 UUID
- 金额字段使用 `Decimal(10, 0)`
- 关键唯一约束:`TimeSlot``@@unique([date, startTime, endTime])``Booking``@@unique([userId, timeSlotId])`
### 核心业务规则
- 预约需要有效的会员卡(剩余次数或有效期内)
- 取消预约需在课程开始前 `cancelHoursLimit` 小时(默认 2 小时,可在 StudioConfig 中配置)
- 时段根据 WeekTemplate 自动生成未来 14 天的课程
- 默认时段容量为 1私教课
## 环境配置
需要 Node 20+.nvmrc、pnpm 8+、PostgreSQL。复制 `packages/server/.env.example``.env.local`,需配置 DATABASE_URL、JWT_SECRET 及微信相关凭证APPID、SECRET、MCH_ID、MCH_KEY、证书路径
## 开发约定
- **API 前缀**:所有路由在 `/api`setGlobalPrefix
- **参数校验**:全局 ValidationPipe启用 whitelist + forbidNonWhitelisted + transform
- **鉴权守卫**:受保护路由使用 `@UseGuards(JwtAuthGuard)`,通过 `@Req()` 从 JWT 载荷提取用户
- **角色**MEMBER 和 ADMIN管理员路由使用自定义角色守卫
- **异常处理**:使用 NestJS 内置异常BadRequestException、NotFoundException 等)
- **分页**:统一使用 `PaginatedResponse<T>`,包含 data、total、page、limit
- **pnpm**:使用 `shamefully-hoist=true`.npmrc为 Uni-app 兼容所需
### Admin Store (`src/stores/admin.ts`)
- 聚合所有管理端 API 调用weekTemplates、cardTypes、studioConfig、members、bookings、orders、stats 等
- 遵循不可变更新原则:`data` 赋值使用展开运算符 `[...newData]`

338
docs/STUDIO_COS_SETUP.md Normal file
View File

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

606
docs/TIME_SLOT_DIAGRAMS.md Normal file
View File

@@ -0,0 +1,606 @@
# Time-Slot & Scheduling System - Architecture Diagrams
## 1. Data Model Relationships
```
┌─────────────────────────────────────────────────────────────────┐
│ WEEK TEMPLATE │
│ │
│ dayOfWeek (1-7, ISO standard) │
│ startTime, endTime (e.g., "09:00", "10:00") │
│ capacity (default 1) │
│ isActive (can disable template) │
│ │
│ ↓ (auto-generates) │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ TIME SLOT │
│ │
│ date (calendar date, midnight UTC) │
│ startTime, endTime (from template) │
│ capacity (from template) │
│ bookedCount (# of current bookings) │
│ status (OPEN | FULL | CLOSED) │
│ source (TEMPLATE | MANUAL) │
│ templateId (reference to WeekTemplate) │
│ │
│ ↓ (has many) ↓ (belongs to) │
│ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
↑ ↑
│ │
└────────────┬─────────────────────────
│ 1:1 booking
┌─────────────────────────────────────────────────────────────────┐
│ BOOKING │
│ │
│ userId (FK to User) │
│ timeSlotId (FK to TimeSlot) │
│ membershipId (FK to Membership) │
│ status (CONFIRMED | CANCELLED | COMPLETED | NO_SHOW) │
│ cancelledAt (timestamp when cancelled) │
│ │
│ Constraints: │
│ - Unique [userId, timeSlotId] (one booking per user per slot) │
│ - ONE booking per TimeSlot per user │
└─────────────────────────────────────────────────────────────────┘
↑ ↑
│ │
└─────────────┬───────────┘
belongs to
┌─────────────────────────────────────────────────────────────────┐
│ MEMBERSHIP │
│ │
│ userId (FK to User) │
│ cardTypeId (FK to CardType) │
│ remainingTimes (for TIMES/TRIAL card types) │
│ expireDate (for DURATION card types) │
│ status (ACTIVE | EXPIRED | USED_UP) │
└─────────────────────────────────────────────────────────────────┘
```
---
## 2. Daily Scheduler Timeline
```
00:00 ─────────────────────────────────────────────────
├─ [Midnight] - Time passes
├─ System running in background
├─ ... (various other operations)
02:00 ─────────────────────────────────────────────────
├─► 🟢 SLOT GENERATION
│ SlotGeneratorService.generateSlots(14)
│ ├─ Query WeekTemplate (all isActive=true)
│ ├─ For each day in [tomorrow, tomorrow+14):
│ │ ├─ Get ISO weekday
│ │ ├─ Find matching templates
│ │ └─ Create TimeSlot entries
│ ├─ Batch insert with skipDuplicates: true
│ └─ Log: "Generated X new time slots"
02:30 ─────────────────────────────────────────────────
├─► 🟡 SLOT CLEANUP
│ SlotGeneratorService.cleanupExpiredSlots()
│ ├─ Find all OPEN slots with date < TODAY
│ ├─ Mark as CLOSED
│ └─ Log: "Closed X expired time slots"
03:00 ─────────────────────────────────────────────────
├─► 🟠 MEMBERSHIP CHECK
│ SlotGeneratorService.checkExpiredMemberships()
│ ├─ Update ACTIVE memberships with expireDate < NOW
│ │ └─ Set status = EXPIRED
│ ├─ Update ACTIVE memberships with remainingTimes = 0
│ │ └─ Set status = USED_UP
│ └─ Log: "Expired X by date, Y by sessions"
├─ ... (users awake, making bookings)
22:00 ─────────────────────────────────────────────────
├─► 🔴 BOOKING COMPLETION
│ SlotGeneratorService.completeBookings()
│ ├─ Find CONFIRMED bookings with timeSlot.date < TODAY
│ ├─ Mark as COMPLETED
│ └─ Log: "Completed X past bookings"
└─ (Day ends, repeat tomorrow)
```
---
## 3. Booking Lifecycle
```
┌─────────────────────────────────────────────────────────┐
│ BOOKING CREATION │
│ (POST /booking) │
└─────────────────────────────────────────────────────────┘
├─ Input: { timeSlotId, membershipId }
├─ TRANSACTION START ──────────────────
│ │
│ ├─► Fetch TimeSlot
│ │ └─ Check: status = OPEN? ✓
│ │
│ ├─► Check Duplicate
│ │ └─ Query: SELECT * FROM bookings WHERE userId=? AND timeSlotId=?
│ │ └─ Must not exist
│ │
│ ├─► Fetch Membership
│ │ └─ Check: belongs to user? ✓
│ │ └─ Check: status = ACTIVE? ✓
│ │ └─ Check: has capacity?
│ │ └─ IF TIMES/TRIAL: remainingTimes > 0? ✓
│ │ └─ IF DURATION: expireDate > NOW? ✓
│ │
│ ├─► CREATE Booking(CONFIRMED)
│ │ └─ INSERT: { userId, timeSlotId, membershipId, status: CONFIRMED }
│ │
│ ├─► UPDATE TimeSlot
│ │ ├─ bookedCount = bookedCount + 1
│ │ ├─ IF bookedCount >= capacity:
│ │ │ └─ status = FULL
│ │ └─ ELSE:
│ │ └─ status = OPEN (unchanged)
│ │
│ ├─► UPDATE Membership (if TIMES/TRIAL)
│ │ ├─ remainingTimes = remainingTimes - 1
│ │ ├─ IF remainingTimes <= 0:
│ │ │ └─ status = USED_UP
│ │ └─ ELSE:
│ │ └─ status = ACTIVE (unchanged)
│ │
│ └─ TRANSACTION COMMIT ──────────────
└─► Return: BookingWithRelations (includes timeSlot, membership)
┌─────────────────────────────────────────────────────────┐
│ BOOKING CANCELLATION │
│ (PUT /booking/:id/cancel) │
└─────────────────────────────────────────────────────────┘
├─ Fetch Booking + TimeSlot + Membership
├─ Check: booking.status = CONFIRMED? ✓
├─ Calculate Refund Eligibility
│ │
│ ├─ cancelHoursLimit = StudioConfig.cancelHoursLimit (default 2)
│ ├─ slotStartMs = Date(timeSlot.date) + timeSlot.startTime
│ ├─ deadlineMs = NOW + (cancelHoursLimit * 3600 * 1000)
│ │
│ ├─ IF slotStartMs >= deadlineMs:
│ │ └─ withinLimit = TRUE ✓ (User gets refund)
│ └─ ELSE:
│ └─ withinLimit = FALSE (No refund)
├─ TRANSACTION START ──────────────────
│ │
│ ├─► UPDATE Booking
│ │ ├─ status = CANCELLED
│ │ └─ cancelledAt = NOW
│ │
│ ├─► UPDATE TimeSlot
│ │ ├─ bookedCount = MAX(0, bookedCount - 1)
│ │ ├─ IF slot was FULL:
│ │ │ └─ status = OPEN
│ │ └─ ELSE:
│ │ └─ status = (unchanged)
│ │
│ ├─► IF withinLimit = TRUE:
│ │ └─ UPDATE Membership (if TIMES/TRIAL)
│ │ ├─ remainingTimes = remainingTimes + 1
│ │ ├─ IF was USED_UP:
│ │ │ └─ status = ACTIVE
│ │ └─ ELSE:
│ │ └─ status = (unchanged)
│ │
│ └─ TRANSACTION COMMIT ──────────────
└─► Return: { booking, refunded: boolean }
```
---
## 4. Slot Generation from Template
```
Template Setup:
┌─────────────────────────────────────────────────────┐
│ PUT /admin/week-template │
│ │
│ { │
│ "templates": [ │
│ { │
│ "dayOfWeek": 1, // Monday (ISO standard)│
│ "startTime": "09:00", │
│ "endTime": "10:00", │
│ "capacity": 1, // Private lesson │
│ "isActive": true │
│ }, │
│ { │
│ "dayOfWeek": 5, // Friday (ISO standard)│
│ "startTime": "18:00", │
│ "endTime": "19:00", │
│ "capacity": 1, │
│ "isActive": true │
│ } │
│ ] │
│ } │
└─────────────────────────────────────────────────────┘
Stored in database as WeekTemplates
Each day at 02:00 UTC, generateSlots(14) runs:
Today: Monday, April 7, 2026
├─ Tomorrow = Tuesday, April 8
├─ For next 14 days:
│ Day 0: Tue (ISO 2) → no matching template → skip
│ Day 1: Wed (ISO 3) → no matching template → skip
│ Day 2: Thu (ISO 4) → no matching template → skip
│ Day 3: Fri (ISO 5) → MATCH! template (18:00-19:00)
│ └─ CREATE TimeSlot(date=Apr12, time=18:00-19:00, capacity=1)
│ Day 4: Sat (ISO 6) → no matching template → skip
│ Day 5: Sun (ISO 7) → no matching template → skip
│ Day 6: Mon (ISO 1) → MATCH! template (09:00-10:00)
│ └─ CREATE TimeSlot(date=Apr14, time=09:00-10:00, capacity=1)
│ Day 7: Tue (ISO 2) → no matching template → skip
│ ... (repeats pattern)
└─ All created with:
├─ status = OPEN
├─ bookedCount = 0
├─ source = TEMPLATE
├─ templateId = (reference to template)
└─ skipDuplicates = true (safe to re-run)
Result:
14 Friday 18:00-19:00 slots generated
14 Monday 09:00-10:00 slots generated
Total: 28 new slots
```
---
## 5. User Booking Flow (Frontend → Backend)
```
┌──────────────────────────────────────────────────────┐
│ MEMBER CLIENT │
└──────────────────────────────────────────────────────┘
│ 1. Click "View Available Slots"
├─► GET /time-slot/available?date=2026-04-10
│ Response: [{
│ id: "slot-123",
│ date: "2026-04-10",
│ startTime: "09:00",
│ endTime: "10:00",
│ status: "OPEN",
│ bookedCount: 0,
│ capacity: 1,
│ isBookedByMe: false, ← User's booking status
│ myBookingId: null
│ }, ...]
├─ Display available slots in UI
│ 2. User selects slot and membership
├─► POST /booking
│ Body: {
│ "timeSlotId": "slot-123",
│ "membershipId": "mem-456"
│ }
│ Response: {
│ id: "booking-789",
│ userId: "user-001",
│ timeSlotId: "slot-123",
│ status: "CONFIRMED",
│ createdAt: "2026-04-05T10:30:00Z",
│ timeSlot: { ... }, ← Full slot details
│ membership: { ... } ← Full membership details
│ }
├─ Display confirmation
│ 3. [Later] User cancels booking
└─► PUT /booking/booking-789/cancel
Response: {
booking: { ... },
refunded: true ← Was refund issued?
}
Display: "Booking cancelled. You've been refunded."
```
---
## 6. State Transitions
### TimeSlot Status
```
┌─────────────────────────────────┐
│ AUTO-GENERATED │
│ by generateSlots() │
└─────────────┬───────────────────┘
┌─────────────────┐
│ OPEN │ ← Can accept bookings
│ (bookedCount < │ bookedCount starts at 0
│ capacity) │
└────────┬────────┘
┌──────────┼──────────┐
│ │ │
│ │ │
[booking │ [cleanup
creates] │ or manual
│ close]
│ │ │
↓ ↓ ↓
FULL CLOSED
(bookedCount >= capacity)
│ [booking cancelled]
OPEN (back to)
Once slot date passes:
├─ OPEN → CLOSED (by cleanup job at 02:30 UTC)
├─ FULL → CLOSED (when cleanup runs)
└─ CANCELLED bookings don't affect slot status
```
### Booking Status
```
┌──────────────────┐
│ CONFIRMED │ ← Default when created
│ │ User has active reservation
└────────┬─────────┘
┌─────┼─────┐
│ │ │
[user │ [auto-mark
cancels] │ when date
│ │ passes]
│ │ │
↓ ↓ ↓
CANCELLED COMPLETED
(free (slot time
cancellation has passed)
until deadline)
CANCELLED bookings stay in history
COMPLETED bookings show in past bookings
CONFIRMED bookings show in upcoming bookings
```
### Membership Status
```
┌─────────────────┐
│ ACTIVE │ ← Can book classes
│ │ Has remaining capacity
└────────┬────────┘
┌─────┼─────┐
│ │ │
[booking │ [auto-check
depletes │ by scheduler
sessions] │ at 03:00 UTC]
│ │ │
↓ ↓ ↓
USED_UP EXPIRED
(for (for
TIMES) DURATION)
USED_UP: remainingTimes = 0 (for TIMES/TRIAL only)
EXPIRED: expireDate < NOW (for DURATION) OR date-based expiry
All non-ACTIVE statuses prevent new bookings
```
---
## 7. Timezone & Date Handling
```
User Timezone: Local (browser/app)
API Timezone: UTC (backend)
Database: UTC
┌──────────────────────────────────────────────┐
│ User in Shanghai (UTC+8) │
│ Local time: 2026-04-10 15:00:00 CST │
│ UTC time: 2026-04-10 07:00:00 UTC │
└──────────────────────────────────────────────┘
├─ Query: GET /time-slot/available?date=2026-04-10
│ (User sends local date, frontend converts to ISO)
├─ Backend receives:
│ ├─ Parse "2026-04-10"
│ ├─ Build start of day: 2026-04-10T00:00:00 UTC
│ ├─ Build end of day: 2026-04-10T23:59:59.999 UTC
│ ├─ Query TimeSlots WHERE date BETWEEN [00:00, 23:59]
└─ Return slots for that calendar day in UTC
┌──────────────────────────────────────────────┐
│ TimeSlot Storage (Database) │
│ │
│ date: 2026-04-10 (DATE type, midnight UTC) │
│ startTime: "09:00" (string, no timezone) │
│ endTime: "10:00" (string, no timezone) │
│ │
│ When combined: │
│ Slot datetime = 2026-04-10T09:00:00 UTC │
└──────────────────────────────────────────────┘
├─ For Shanghai user (UTC+8):
│ └─ 09:00 UTC = 17:00 CST (5 PM)
└─ For New York user (UTC-4):
└─ 09:00 UTC = 05:00 EDT (5 AM)
Scheduler (UTC times):
┌─────────────────────────────────────────────┐
│ 02:00 UTC = Generate slots │
│ 02:30 UTC = Cleanup │
│ 03:00 UTC = Check memberships │
│ 22:00 UTC = Complete bookings │
│ │
│ When scheduler checks "is date < today?": │
│ ├─ Create midnight UTC boundary │
│ ├─ Compare slot.date < today's midnight │
│ └─ Mark as CLOSED/COMPLETED if older │
└─────────────────────────────────────────────┘
```
---
## 8. Error Handling Tree
```
POST /booking
├─ TimeSlot not found
│ └─ Return: NotFoundException
├─ TimeSlot.status ≠ OPEN
│ └─ Return: BadRequestException("TimeSlot is not available")
├─ Duplicate booking exists
│ └─ Return: ConflictException("Already booked this slot")
├─ Membership not found
│ └─ Return: NotFoundException
├─ Membership.userId ≠ current user
│ └─ Return: ForbiddenException("Not your membership")
├─ Membership.status ≠ ACTIVE
│ └─ Return: BadRequestException("Membership inactive")
├─ Card type is TIMES/TRIAL:
│ │
│ └─ remainingTimes ≤ 0
│ └─ Return: BadRequestException("No remaining times")
└─ Card type is DURATION:
└─ expireDate < NOW
└─ Return: BadRequestException("Membership expired")
PUT /booking/:id/cancel
├─ Booking not found
│ └─ Return: NotFoundException
├─ Booking.userId ≠ current user
│ └─ Return: ForbiddenException("Not your booking")
├─ Booking.status ≠ CONFIRMED
│ └─ Return: BadRequestException("Can't cancel this status")
└─ ✓ Cancel successful
├─ Check refund eligibility
├─ Update booking status
├─ Update timeSlot bookedCount
└─ Conditionally refund membership
```
---
## 9. Integration Points
```
┌─────────────────────────────────────────────────────┐
│ APP MODULE │
│ (packages/server/src/app.module.ts) │
└─────────────────────────────────────────────────────┘
├─ imports: [
│ AuthModule,
│ TimeSlotModule, ← Time-Slot logic
│ SchedulerModule, ← Auto jobs (cron)
│ BookingModule, ← Booking logic
│ MembershipModule, ← Membership checks
│ StudioModule, ← Config (cancelHoursLimit)
│ ...
│ ]
└─ Controllers route to:
├─ TimeSlotController (public slots viewing)
├─ AdminTimeSlotController (templates, admin actions)
├─ BookingController (create, cancel bookings)
└─ ... (other endpoints)
SchedulerModule dependencies:
├─ ScheduleModule.forRoot() ← Enable @Cron decorators
└─ TimeSlotModule ← Access to SlotGeneratorService
BookingModule dependencies:
├─ MembershipModule ← Check membership status
└─ StudioModule ← Read cancelHoursLimit config
Services call chain:
├─ Controller
│ ├─ TimeSlotService
│ │ └─ PrismaService
│ └─ BookingService
│ ├─ PrismaService
│ ├─ MembershipService
│ └─ StudioService
```

364
docs/TIME_SLOT_INDEX.md Normal file
View File

@@ -0,0 +1,364 @@
# Time-Slot & Scheduling System - Documentation Index
This directory contains comprehensive documentation of the NestJS backend time-slot and scheduling system for the pilates studio booking platform.
## 📚 Documentation Files
### 1. **TIME_SLOT_SCHEDULING_SYSTEM.md** (966 lines, 24KB)
**Most comprehensive reference** - Full system analysis with all details
**Contents:**
- Executive Summary
- Data Models (WeekTemplate, TimeSlot, Booking) with Prisma schema
- SlotGeneratorService (4 key methods: generateSlots, cleanupExpiredSlots, checkExpiredMemberships, completeBookings)
- TimeSlotService (queries and management)
- TimeSlotController & AdminTimeSlotController (all endpoints)
- SchedulerService (4 daily cron jobs at 02:00, 02:30, 03:00, 22:00 UTC)
- BookingService (integration with time slots)
- Data Flow Diagrams
- DTOs & Request/Response examples
- Shared Constants & Enums
- File Structure Summary
- Key Architectural Patterns
- Example Scenarios
- Testing Guide
- Configuration & Environment
- Performance Considerations
- Security Notes
- Future Enhancement Ideas
**When to use:** Deep dive into how the system works, understanding all components
---
### 2. **TIME_SLOT_QUICK_REFERENCE.md** (355 lines, 9KB)
**Quick lookup guide** - Essential information at a glance
**Contents:**
- File Locations (all key files in one table)
- Key Concepts (WeekTemplate, TimeSlot, Booking)
- Daily Scheduler Jobs (quick table with times and purposes)
- Important Methods (TypeScript signatures for all key methods)
- API Endpoints (member and admin endpoints with request/response)
- Status Values (all enum values explained)
- Key Logic (booking creation & cancellation flows in pseudocode)
- Weekday Mapping (ISO standard vs JavaScript)
- Database Constraints
- Configuration
- Common Errors (troubleshooting table)
- Testing
- Development Workflow
- Architecture Highlights
**When to use:** Quick lookup while coding, API reference, debugging errors
---
### 3. **TIME_SLOT_DIAGRAMS.md** (606 lines, 25KB)
**Visual references** - ASCII diagrams and flowcharts
**Contents:**
1. Data Model Relationships (entity diagram)
2. Daily Scheduler Timeline (24-hour cron schedule visualization)
3. Booking Lifecycle (detailed creation and cancellation flows)
4. Slot Generation from Template (step-by-step with example)
5. User Booking Flow (frontend → backend interaction)
6. State Transitions (TimeSlot, Booking, Membership status flows)
7. Timezone & Date Handling (UTC, local time conversion)
8. Error Handling Tree (decision tree for POST /booking and cancellation)
9. Integration Points (module dependencies)
**When to use:** Understanding the big picture, presenting to team, tracing flow execution
---
## 🔍 Key Information at a Glance
### Source Code Locations
```
Backend Time-Slot System:
├── packages/server/src/time-slot/
│ ├── slot-generator.service.ts (172 lines)
│ ├── time-slot.service.ts (142 lines)
│ ├── time-slot.controller.ts (93 lines)
│ ├── time-slot.module.ts
│ └── dto/
│ ├── query-slots.dto.ts
│ ├── create-manual-slot.dto.ts
│ └── week-template.dto.ts
├── packages/server/src/scheduler/
│ ├── scheduler.service.ts (55 lines)
│ └── scheduler.module.ts
├── packages/server/src/booking/
│ ├── booking.service.ts (367 lines)
│ ├── booking.controller.ts (82 lines)
│ ├── booking.module.ts
│ └── dto/
│ └── create-booking.dto.ts
├── packages/server/prisma/
│ └── schema.prisma (Models: WeekTemplate, TimeSlot, Booking)
└── packages/shared/src/
├── constants.ts (Slot generation, capacity defaults)
├── enums.ts (TimeSlotStatus, BookingStatus, etc.)
└── types/
└── time-slot.ts (Type definitions)
```
### Daily Scheduler (UTC)
| Time | Job | Method |
|------|-----|--------|
| 02:00 | Generate 14 days of slots | `SlotGeneratorService.generateSlots(14)` |
| 02:30 | Close expired OPEN slots | `SlotGeneratorService.cleanupExpiredSlots()` |
| 03:00 | Expire memberships | `SlotGeneratorService.checkExpiredMemberships()` |
| 22:00 | Complete past bookings | `SlotGeneratorService.completeBookings()` |
### Important Constants
```
DEFAULT_SLOT_CAPACITY = 1 (private lessons)
SLOT_GENERATION_DAYS = 14 (days ahead to auto-generate)
DEFAULT_CANCEL_HOURS_LIMIT = 2 (hours before slot to allow refund)
```
### API Endpoints
**Member:**
```
GET /time-slot/available?date=YYYY-MM-DD
GET /time-slot/:id
POST /booking
PUT /booking/:id/cancel
GET /booking/my
GET /booking/my/upcoming
```
**Admin:**
```
GET /admin/week-template
PUT /admin/week-template
POST /admin/time-slot/manual
PUT /admin/time-slot/:id/close
POST /admin/generate-slots
GET /admin/bookings
```
---
## 🎯 Common Tasks & Where to Find Info
| Task | Reference |
|------|-----------|
| **Understand slot generation algorithm** | TIME_SLOT_SCHEDULING_SYSTEM.md § 2.2 or DIAGRAMS § 4 |
| **See all API endpoints** | QUICK_REFERENCE § "API Endpoints" or TIME_SLOT_SCHEDULING_SYSTEM.md § 4 |
| **Booking creation logic** | TIME_SLOT_DIAGRAMS.md § 3 or QUICK_REFERENCE § "Key Logic" |
| **Weekday mapping (ISO vs JS)** | QUICK_REFERENCE § "Weekday Mapping" or DIAGRAMS § 7 |
| **Cancellation refund policy** | TIME_SLOT_SCHEDULING_SYSTEM.md § 6.1 or DIAGRAMS § 3 |
| **Scheduler jobs timeline** | QUICK_REFERENCE § "Daily Scheduler Jobs" or DIAGRAMS § 2 |
| **Error handling** | QUICK_REFERENCE § "Common Errors" or DIAGRAMS § 8 |
| **Data model relationships** | DIAGRAMS § 1 or TIME_SLOT_SCHEDULING_SYSTEM.md § 1 |
| **Configuration & setup** | QUICK_REFERENCE § "Configuration" or TIME_SLOT_SCHEDULING_SYSTEM.md § 14 |
| **Performance tips** | TIME_SLOT_SCHEDULING_SYSTEM.md § 15 or QUICK_REFERENCE § "Performance Tips" |
| **Module dependencies** | DIAGRAMS § 9 or TIME_SLOT_SCHEDULING_SYSTEM.md § 11.2 |
| **Testing** | TIME_SLOT_SCHEDULING_SYSTEM.md § 13 or QUICK_REFERENCE § "Testing" |
---
## 📋 System Overview
### What It Does
This system manages the complete lifecycle of time slots and bookings for a pilates studio:
1. **Automated Slot Generation**: Every day at 02:00 UTC, generates 14 days of time slots from reusable weekly templates
2. **Capacity Management**: Tracks slot capacity and prevents overbooking
3. **Booking Management**: Allows members to book slots with their memberships
4. **Cancellation & Refunds**: Members can cancel with conditional refunds (within 2-hour window)
5. **Membership Expiration**: Automatically expires memberships by date or used sessions
6. **Cleanup**: Marks past slots as closed and completed bookings as finished
### Key Concepts
- **WeekTemplate**: Defines recurring schedule (e.g., "Monday 09:00-10:00")
- **TimeSlot**: Individual class instance (e.g., "April 10, 2026 09:00-10:00")
- **Booking**: User's reservation (links user + slot + membership)
- **Status Tracking**: OPEN → FULL → CLOSED (slots) and CONFIRMED → COMPLETED (bookings)
### Architecture Highlights
**Idempotent** - Safe to re-run slot generation
**Transactional** - ACID compliance for bookings
**Automated** - 4 daily cron jobs maintain state
**Flexible** - Supports TIMES, DURATION, and TRIAL memberships
**Scalable** - Batch operations, proper database indexes
**Secure** - Role-based access, comprehensive validation
---
## 🚀 Getting Started
### For New Developers
1. **Start with**: TIME_SLOT_QUICK_REFERENCE.md
- Get oriented with file locations and key methods
2. **Then read**: TIME_SLOT_DIAGRAMS.md § 1 (Data Model)
- Understand how entities relate
3. **Deep dive**: TIME_SLOT_SCHEDULING_SYSTEM.md § 2
- Study the SlotGeneratorService algorithm
4. **Explore the code**: Read actual source files for implementation details
### For System Integration
1. Review TIME_SLOT_DIAGRAMS.md § 9 (Integration Points)
2. Check the module imports in `app.module.ts`
3. Understand dependencies in QUICK_REFERENCE.md § "Configuration"
### For API Integration
1. Start with TIME_SLOT_QUICK_REFERENCE.md § "API Endpoints"
2. See examples in TIME_SLOT_SCHEDULING_SYSTEM.md § 12
3. Check DTOs in TIME_SLOT_SCHEDULING_SYSTEM.md § 8
### For Debugging
1. Check common errors in QUICK_REFERENCE.md § "Common Errors"
2. Trace error handling in DIAGRAMS.md § 8
3. Review actual error handling in source code
---
## 📖 Reading Recommendations by Role
### Backend Developer
1. TIME_SLOT_SCHEDULING_SYSTEM.md (all)
2. TIME_SLOT_DIAGRAMS.md (all)
3. Source code in `packages/server/src/time-slot/`
### Frontend Developer
1. TIME_SLOT_QUICK_REFERENCE.md (API Endpoints section)
2. TIME_SLOT_SCHEDULING_SYSTEM.md § 12 (Example Scenarios)
3. TIME_SLOT_DIAGRAMS.md § 5 (User Booking Flow)
### DevOps / Sysadmin
1. TIME_SLOT_SCHEDULING_SYSTEM.md § 14 (Configuration)
2. TIME_SLOT_QUICK_REFERENCE.md § "Daily Scheduler Jobs"
3. TIME_SLOT_DIAGRAMS.md § 2 (Scheduler Timeline)
### Product Manager
1. TIME_SLOT_SCHEDULING_SYSTEM.md § "Executive Summary"
2. TIME_SLOT_DIAGRAMS.md § 3 & 5 (Booking flows)
3. TIME_SLOT_QUICK_REFERENCE.md § "Architecture Highlights"
### QA / Tester
1. TIME_SLOT_QUICK_REFERENCE.md (all)
2. TIME_SLOT_SCHEDULING_SYSTEM.md § 13 (Testing Guide)
3. TIME_SLOT_SCHEDULING_SYSTEM.md § 12 (Example Scenarios)
---
## 🔗 Related Documentation
- **Database Schema**: See `packages/server/prisma/schema.prisma` (lines 113-168)
- **Shared Types**: See `packages/shared/src/types/` and `enums.ts`
- **Authentication**: See booking endpoints require JwtAuthGuard
- **Membership System**: See `BookingService` integration with `MembershipService`
- **Studio Config**: See `StudioService` for `cancelHoursLimit`
---
## 📊 Document Statistics
| File | Lines | Size | Topics |
|------|-------|------|--------|
| TIME_SLOT_SCHEDULING_SYSTEM.md | 966 | 24KB | 17 comprehensive sections |
| TIME_SLOT_QUICK_REFERENCE.md | 355 | 9KB | 15 quick-lookup sections |
| TIME_SLOT_DIAGRAMS.md | 606 | 25KB | 9 visual flowcharts |
| **Total** | **1,927** | **58KB** | **Complete system coverage** |
---
## 🎓 Learning Path
```
Entry Level
├─ README.md (this file)
├─ TIME_SLOT_QUICK_REFERENCE.md (20 min read)
└─ TIME_SLOT_DIAGRAMS.md § 1 (5 min)
Intermediate
├─ TIME_SLOT_DIAGRAMS.md (all, 15 min)
├─ TIME_SLOT_QUICK_REFERENCE.md (re-read, 15 min)
└─ TIME_SLOT_SCHEDULING_SYSTEM.md § 1-6 (30 min)
Advanced
├─ TIME_SLOT_SCHEDULING_SYSTEM.md (full, 60 min)
├─ Source code reading (packages/server/src/time-slot/)
└─ Prisma schema study
Expert
└─ Code review + contributions
```
---
## 🤝 Contributing
When adding features or making changes:
1. **Update the code** in `packages/server/src/time-slot/` and related modules
2. **Update tests** in `__tests__/` directories
3. **Update documentation** in this docs folder if behavior changes
4. Use the **Quick Reference** as checklist for all affected pieces
---
## ❓ FAQ
**Q: Where do time slots come from?**
A: Auto-generated from WeekTemplates every day at 02:00 UTC by `generateSlots(14)`.
**Q: Can I disable slot generation?**
A: Yes, make templates `isActive: false` or disable the cron job in `scheduler.service.ts`.
**Q: How is capacity managed?**
A: `bookedCount` increments on booking, slot status becomes FULL when `bookedCount >= capacity`.
**Q: What if I cancel a booking?**
A: `bookedCount` decrements; if within 2-hour window, membership refunded; slot status restored if was FULL.
**Q: Timezone support?**
A: All times stored in UTC. Scheduler uses UTC times (02:00, 02:30, etc.). See DIAGRAMS § 7.
**Q: How are memberships expired?**
A: Automatically by scheduler job at 03:00 UTC daily; marks EXPIRED if date passed or USED_UP if sessions depleted.
---
## 📞 Quick Reference Card
### Status Values
- **TimeSlot**: OPEN | FULL | CLOSED
- **Booking**: CONFIRMED | CANCELLED | COMPLETED | NO_SHOW
- **Membership**: ACTIVE | EXPIRED | USED_UP
### Key Dates & Times
- **Slot generation**: Daily 02:00 UTC (14 days ahead)
- **Cleanup**: Daily 02:30 UTC
- **Membership check**: Daily 03:00 UTC
- **Booking completion**: Daily 22:00 UTC
- **Cancellation window**: 2 hours before slot (configurable)
### Key Files
- **Slot generation**: `slot-generator.service.ts`
- **Slot queries**: `time-slot.service.ts`
- **Booking logic**: `booking.service.ts`
- **Database**: `prisma/schema.prisma`

View File

@@ -0,0 +1,355 @@
# Time-Slot & Scheduling System - Quick Reference
## File Locations
| Component | Path |
|-----------|------|
| **Slot Generator** | `packages/server/src/time-slot/slot-generator.service.ts` |
| **TimeSlot Service** | `packages/server/src/time-slot/time-slot.service.ts` |
| **TimeSlot Controller** | `packages/server/src/time-slot/time-slot.controller.ts` |
| **Scheduler** | `packages/server/src/scheduler/scheduler.service.ts` |
| **Booking Service** | `packages/server/src/booking/booking.service.ts` |
| **Booking Controller** | `packages/server/src/booking/booking.controller.ts` |
| **Database Schema** | `packages/server/prisma/schema.prisma` |
| **Shared Constants** | `packages/shared/src/constants.ts` |
| **Shared Enums** | `packages/shared/src/enums.ts` |
---
## Key Concepts
### WeekTemplate
Defines **recurring class schedule** by day of week (1=Monday, 7=Sunday) and time.
- Used to auto-generate TimeSlots nightly
- Can be enabled/disabled
- Has capacity (default 1 for private lessons)
### TimeSlot
**Individual class instance** on a specific date with a specific time.
- Status: OPEN → FULL → CLOSED
- Source: TEMPLATE (auto-generated) or MANUAL (admin-created)
- Cannot have duplicates (unique constraint on date+startTime+endTime)
### Booking
**User's reservation** for a specific TimeSlot.
- Status: CONFIRMED → COMPLETED (or CANCELLED)
- Links user + timeSlot + membership
- Unique constraint: one booking per user per slot
---
## Daily Scheduler Jobs
All times in UTC:
| Time | Job | What It Does |
|------|-----|--------------|
| **02:00** | `handleSlotGeneration()` | Generate slots 14 days ahead from WeekTemplates |
| **02:30** | `handleCleanupSlots()` | Mark past OPEN slots as CLOSED |
| **03:00** | `handleCheckMemberships()` | Expire memberships by date or used-up sessions |
| **22:00** | `handleCompleteBookings()` | Mark past CONFIRMED bookings as COMPLETED |
---
## Important Methods
### SlotGeneratorService
```typescript
// Generate N days of slots from WeekTemplates
generateSlots(daysAhead = 14): Promise<number>
// Close all past OPEN slots
cleanupExpiredSlots(): Promise<number>
// Expire memberships by date or session count
checkExpiredMemberships(): Promise<number>
// Mark past bookings as COMPLETED
completeBookings(): Promise<number>
```
### TimeSlotService
```typescript
// Get all slots for a date (with user's booking status if provided)
getAvailableSlots(date: string, userId?: string): Promise<TimeSlotWithBookingStatus[]>
// Manually create a one-off slot
createManualSlot(dto: CreateManualSlotDto): Promise<TimeSlot>
// Close a slot (prevent new bookings)
closeSlot(id: string): Promise<TimeSlot>
// Get/replace weekly templates
getWeekTemplates(): Promise<WeekTemplate[]>
replaceWeekTemplates(items: WeekTemplateItemDto[]): Promise<CreateBatchPayload>
```
### BookingService
```typescript
// Create a booking (validates slot/membership, updates counts)
createBooking(userId: string, dto: CreateBookingDto): Promise<BookingWithRelations>
// Cancel a booking (conditionally refunds membership)
cancelBooking(userId: string, bookingId: string): Promise<CancelBookingResult>
// Get user's bookings (paginated, filterable by status)
getMyBookings(userId: string, status?, page, limit): Promise<PaginatedResult>
// Get all CONFIRMED bookings for dates >= today
getUpcomingBookings(userId: string): Promise<BookingWithRelations[]>
```
---
## API Endpoints
### Member Endpoints
```
GET /time-slot/available?date=2026-04-10
→ Returns slots for that date with user's booking status
GET /time-slot/:id
→ Returns full slot details with all bookings
POST /booking
Body: { "timeSlotId": "uuid", "membershipId": "uuid" }
→ Create a booking
PUT /booking/:id/cancel
→ Cancel a booking (refund if within window)
GET /booking/my?status=CONFIRMED&page=1&limit=10
→ Get user's bookings (paginated)
GET /booking/my/upcoming
→ Get all upcoming CONFIRMED bookings
```
### Admin Endpoints
```
GET /admin/week-template
→ List all templates
PUT /admin/week-template
Body: { "templates": [ {...}, {...} ] }
→ Replace all templates (atomic)
POST /admin/time-slot/manual
Body: { "date", "startTime", "endTime", "capacity" }
→ Create a one-off slot
PUT /admin/time-slot/:id/close
→ Close a slot
POST /admin/generate-slots
→ Manually trigger slot generation
GET /admin/bookings?page=1&limit=10&status=CONFIRMED
→ View all bookings (admin)
```
---
## Status Values
### TimeSlotStatus
- **OPEN**: Accepts bookings (bookedCount < capacity)
- **FULL**: At capacity (bookedCount >= capacity)
- **CLOSED**: Past date or manually closed
### BookingStatus
- **CONFIRMED**: Active reservation
- **CANCELLED**: User cancelled
- **COMPLETED**: Slot time has passed
- **NO_SHOW**: Marked manually
### MembershipStatus
- **ACTIVE**: Valid for booking
- **EXPIRED**: End date passed
- **USED_UP**: No remaining sessions (for TIMES/TRIAL)
### CardTypeCategory
- **TIMES**: N sessions (e.g., "5-pack")
- **DURATION**: Valid for X days (e.g., "1-month")
- **TRIAL**: Free trial sessions
---
## Key Logic
### Booking Creation Transaction
```
1. Validate TimeSlot exists and status = OPEN
2. Check user not already booked this slot
3. Validate Membership:
- Belongs to user
- Status = ACTIVE
- Has capacity:
* TIMES/TRIAL: remainingTimes > 0
* DURATION: expireDate > NOW
4. CREATE Booking(CONFIRMED)
5. UPDATE TimeSlot:
- bookedCount++
- IF bookedCount >= capacity THEN status = FULL
6. UPDATE Membership (if time-based):
- remainingTimes--
- IF remainingTimes = 0 THEN status = USED_UP
7. Return with relations
```
### Cancellation Refund Logic
```
cancelHoursLimit = 2 (configurable in StudioConfig)
slotStartTime = TimeSlot.date + TimeSlot.startTime
deadline = NOW + (cancelHoursLimit * hours)
IF slotStartTime >= deadline:
Refund = TRUE
Increment membership.remainingTimes
ELSE:
Refund = FALSE
No membership change
```
---
## Weekday Mapping
**ISO Standard** (what WeekTemplate uses):
```
1 = Monday
2 = Tuesday
3 = Wednesday
4 = Thursday
5 = Friday
6 = Saturday
7 = Sunday
```
**JavaScript getDay()** (what Date does):
```
0 = Sunday
1 = Monday
2 = Tuesday
...
6 = Saturday
```
**Conversion function:**
```typescript
function toIsoWeekday(jsDay: number): number {
return jsDay === 0 ? 7 : jsDay
}
```
---
## Database Constraints
### TimeSlot
- Unique: `[date, startTime, endTime]` - prevents duplicate slots
- Index: `date` - for date range queries
- Index: `status` - for filtering
### Booking
- Unique: `[userId, timeSlotId]` - one booking per user per slot
- Index: `userId` - for user's bookings
- Index: `status` - for status filtering
---
## Configuration
### Environment Variables
```
DATABASE_URL=mysql://... (required)
```
### From StudioConfig Table
```
cancelHoursLimit = 2 (hours before slot to allow free cancellation)
```
### From Shared Constants
```
DEFAULT_SLOT_CAPACITY = 1
SLOT_GENERATION_DAYS = 14
DEFAULT_CANCEL_HOURS_LIMIT = 2
```
---
## Common Errors
| Error | Cause | Solution |
|-------|-------|----------|
| TimeSlot not found | Invalid slot ID | Check slot exists |
| TimeSlot is not available | Status ≠ OPEN | Slot is FULL or CLOSED |
| You have already booked this slot | Duplicate booking | Check user's bookings |
| This membership does not belong to you | Membership not user's | Verify membership |
| Membership is not active | Status ≠ ACTIVE | Renew or purchase membership |
| No remaining times on this membership | remainingTimes ≤ 0 | Purchase more sessions |
| Membership has expired | expireDate < NOW | Renew membership |
| Cannot cancel booking with status | Status ≠ CONFIRMED | Can only cancel CONFIRMED bookings |
---
## Testing
Run tests with:
```bash
npm test -- slot-generator.service.spec.ts
npm test -- booking.service.spec.ts
npm test -- time-slot.service.spec.ts
```
Key test areas:
- Slot generation from templates
- Weekday mapping (JS vs ISO)
- Booking creation with all validations
- Cancellation with/without refund
- Membership expiration
---
## Performance Tips
1. **Avoid N+1 queries** - Always include relations in findMany
2. **Batch operations** - Use createMany/updateMany for large operations
3. **Transactions** - Wrap multi-step operations to prevent race conditions
4. **Indexes** - Queries filter by date and status (both indexed)
---
## Development Workflow
1. **Setup templates**`PUT /admin/week-template`
2. **Manually trigger generation**`POST /admin/generate-slots`
3. **View available slots**`GET /time-slot/available?date=...`
4. **Create booking**`POST /booking`
5. **Cancel booking**`PUT /booking/:id/cancel`
For testing without scheduler:
```typescript
// Inject SlotGeneratorService and call directly
const count = await slotGenerator.generateSlots(7)
```
---
## Architecture Highlights
**Idempotent** - Safe to re-run slot generation
**Transactional** - Bookings are atomic
**Automated** - 4 daily cron jobs maintain state
**Flexible** - Supports multiple membership types
**Scalable** - Batch operations, proper indexes
**Validating** - DTO decorators + business logic checks

View File

@@ -0,0 +1,966 @@
# NestJS Time-Slot & Scheduling System Analysis
## Executive Summary
This is a comprehensive analysis of the pilates studio booking system's time-slot generation and scheduling backend. The system automatically generates time slots from reusable weekly templates, maintains their lifecycle, and integrates tightly with the booking and membership management systems.
---
## 1. Data Models (Prisma Schema)
### 1.1 WeekTemplate Model
**Location:** `packages/server/prisma/schema.prisma` (lines 113-126)
```prisma
model WeekTemplate {
id String @id @default(uuid())
dayOfWeek Int @map("day_of_week") // 1=Mon, 7=Sun (ISO standard)
startTime String @map("start_time") // e.g., "09:00"
endTime String @map("end_time") // e.g., "10:00"
capacity Int @default(1) // Max participants
isActive Boolean @default(true) // Enable/disable template
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
timeSlots TimeSlot[] // Generated slots from this template
}
```
**Purpose:**
- Defines recurring time slots by day of week and time
- Used as blueprint for automatic slot generation
- Capacity defines how many people can book each slot
**Key Constraints:**
- `dayOfWeek` uses **ISO 8601 standard** (1=Monday through 7=Sunday)
- NOT JavaScript getDay() (0=Sunday)
- Conversion happens in SlotGeneratorService.toIsoWeekday()
---
### 1.2 TimeSlot Model
**Location:** `packages/server/prisma/schema.prisma` (lines 128-148)
```prisma
model TimeSlot {
id String @id @default(uuid())
date DateTime @db.Date // Calendar date (midnight UTC)
startTime String @map("start_time") // "HH:mm" format
endTime String @map("end_time") // "HH:mm" format
capacity Int @default(1) // Max participants
bookedCount Int @default(0) // Current bookings
status TimeSlotStatus @default(OPEN) // OPEN | FULL | CLOSED
source TimeSlotSource @default(TEMPLATE) // TEMPLATE | MANUAL
templateId String? @map("template_id") // Reference to WeekTemplate
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
template WeekTemplate? @relation(fields: [templateId], references: [id])
bookings Booking[]
@@unique([date, startTime, endTime]) // Prevent duplicate slots
@@index([date])
@@index([status])
}
```
**Status Lifecycle:**
- **OPEN**: Accepts bookings, bookedCount < capacity
- **FULL**: No more bookings, bookedCount >= capacity
- **CLOSED**: Past date or manually closed, no bookings allowed
**Source Types:**
- **TEMPLATE**: Auto-generated from WeekTemplate
- **MANUAL**: Created directly by admin
---
### 1.3 Booking Model
**Location:** `packages/server/prisma/schema.prisma` (lines 150-168)
```prisma
model Booking {
id String @id @default(uuid())
userId String @map("user_id")
timeSlotId String @map("time_slot_id")
membershipId String @map("membership_id")
status BookingStatus @default(CONFIRMED)
cancelledAt DateTime? @map("cancelled_at")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
timeSlot TimeSlot @relation(fields: [timeSlotId], references: [id])
membership Membership @relation(fields: [membershipId], references: [id])
@@unique([userId, timeSlotId]) // One booking per user per slot
@@index([userId])
@@index([status])
}
```
**Booking Status Values:**
- **CONFIRMED**: Active reservation
- **CANCELLED**: User cancelled
- **COMPLETED**: Slot time has passed
- **NO_SHOW**: Marked manually if user didn't attend
---
## 2. SlotGeneratorService
**Location:** `packages/server/src/time-slot/slot-generator.service.ts`
### 2.1 Service Overview
Core service responsible for:
1. **Generating** time slots from WeekTemplate
2. **Cleaning up** expired slots
3. **Managing** membership expiration
4. **Marking** past bookings as completed
### 2.2 Key Methods
#### `generateSlots(daysAhead: number = 14): Promise<number>`
**Purpose:** Creates time slots for the next N days based on active WeekTemplates.
**Algorithm:**
```
1. Fetch all active WeekTemplates (isActive = true)
2. Calculate tomorrow at midnight UTC as start date
3. For each day in [tomorrow, tomorrow + daysAhead):
a. Get ISO weekday (1-7) from JavaScript date
b. Find matching templates for this weekday
c. For each matching template, create slot data:
- date: UTC midnight
- startTime/endTime: from template
- capacity: from template
- source: TimeSlotSource.TEMPLATE
- templateId: template.id
4. Batch create all slots using createMany() with skipDuplicates: true
5. Return count of newly created slots
```
**Key Features:**
- **Idempotent:** Re-running is safe; duplicate date+startTime+endTime combos are skipped
- **Timezone Aware:** Uses UTC midnight for dates
- **Weekday Mapping:** Converts JS getDay() → ISO weekday
- **Batch Insert:** Creates all slots in single database operation
**Example Execution:**
- Today: Monday, April 7, 2026
- Daylight: 14 days
- Template: Monday 09:00-10:00, Friday 18:00-19:00
- Result: 2 slots tomorrow (Monday), 0 Wed-Thu, 1 Friday, repeat pattern
---
#### `cleanupExpiredSlots(): Promise<number>`
**Purpose:** Marks all OPEN slots with dates before today as CLOSED.
**Logic:**
```sql
UPDATE time_slots
SET status = 'CLOSED'
WHERE status = 'OPEN' AND date < TODAY_MIDNIGHT_UTC
```
**Returns:** Count of slots closed.
---
#### `checkExpiredMemberships(): Promise<number>`
**Purpose:** Manages membership expiration in two ways:
1. **By Expiration Date:**
```
WHERE status = ACTIVE AND expireDate < NOW
SET status = EXPIRED
```
2. **By Used-Up Sessions:**
```
WHERE status = ACTIVE AND remainingTimes = 0
SET status = USED_UP
```
**Returns:** Total count of memberships updated.
---
#### `completeBookings(): Promise<number>`
**Purpose:** Marks CONFIRMED bookings for past time slots as COMPLETED.
**Logic:**
```sql
UPDATE bookings
SET status = 'COMPLETED'
WHERE status = 'CONFIRMED'
AND timeSlot.date < TODAY_MIDNIGHT_UTC
```
---
## 3. TimeSlotService
**Location:** `packages/server/src/time-slot/time-slot.service.ts`
### 3.1 Service Overview
Handles time slot queries and management for both members and admins.
### 3.2 Key Methods
#### `getAvailableSlots(date: string, userId?: string): Promise<TimeSlotWithBookingStatus[]>`
**Purpose:** Retrieve all non-closed slots for a specific date, optionally including user's booking status.
**Query Logic:**
```
1. Parse date string to Date object
2. Find all slots for that calendar day:
- WHERE status != CLOSED
- ORDER BY startTime ASC
3. If userId provided:
- Include bookings where userId=X AND status=CONFIRMED
- Map to "isBookedByMe" and "myBookingId" fields
4. Return TimeSlotWithBookingStatus[]
```
**Response Type:**
```typescript
interface TimeSlotWithBookingStatus {
id: string
date: string // ISO date "YYYY-MM-DD"
startTime: string // "HH:mm"
endTime: string
capacity: number
bookedCount: number
status: TimeSlotStatus // OPEN | FULL | CLOSED
source: TimeSlotSource // TEMPLATE | MANUAL
templateId: string | null
createdAt: string // ISO datetime
updatedAt: string
isBookedByMe: boolean // Current user's booking?
myBookingId: string | null // For cancellation
}
```
---
#### `getSlotById(id: string): Promise<TimeSlot>`
Returns full slot details including all bookings. Throws NotFoundException if not found.
---
#### `createManualSlot(dto: CreateManualSlotDto): Promise<TimeSlot>`
**Purpose:** Allow admins to create one-off time slots outside templates.
**DTO:**
```typescript
class CreateManualSlotDto {
date: string // "YYYY-MM-DD"
startTime: string // "HH:mm"
endTime: string // "HH:mm"
capacity?: number // Defaults to DEFAULT_SLOT_CAPACITY (1)
}
```
**Creates slot with:**
- `source: TimeSlotSource.MANUAL`
- `templateId: null`
---
#### `closeSlot(id: string): Promise<TimeSlot>`
Sets slot status to CLOSED. Prevents new bookings but keeps existing ones.
---
#### `getWeekTemplates(): Promise<WeekTemplate[]>`
Lists all templates ordered by dayOfWeek and startTime.
---
#### `replaceWeekTemplates(items: WeekTemplateItemDto[]): Promise<CreateBatchPayload>`
**Purpose:** Atomic replacement of all templates (used during admin config).
**Transaction:**
```
1. DELETE FROM week_templates (all rows)
2. CREATE week_templates with new items
3. Return count
```
---
## 4. TimeSlotController & AdminTimeSlotController
**Location:** `packages/server/src/time-slot/time-slot.controller.ts`
### 4.1 Member Endpoints
#### `GET /time-slot/available?date=YYYY-MM-DD`
- Returns available slots for the date
- Includes current user's booking status
- Requires JWT authentication
#### `GET /time-slot/:id`
- Returns full slot details with all bookings
- Requires JWT authentication
---
### 4.2 Admin Endpoints
All require `@Roles(UserRole.ADMIN)` and JWT auth.
#### `GET /admin/week-template`
Lists all WeekTemplate entries.
#### `PUT /admin/week-template`
Replaces all templates. Request body:
```json
{
"templates": [
{
"dayOfWeek": 1,
"startTime": "09:00",
"endTime": "10:00",
"capacity": 1,
"isActive": true
}
]
}
```
#### `POST /admin/time-slot/manual`
Creates a manual slot. Request body:
```json
{
"date": "2026-04-10",
"startTime": "14:00",
"endTime": "15:00",
"capacity": 2
}
```
#### `PUT /admin/time-slot/:id/close`
Closes a specific slot.
#### `POST /admin/generate-slots`
Manually trigger slot generation (default 14 days ahead).
---
## 5. SchedulerService - Automated Jobs
**Location:** `packages/server/src/scheduler/scheduler.service.ts`
### 5.1 Overview
Uses `@nestjs/schedule` to run daily maintenance tasks. All times in UTC.
### 5.2 Cron Jobs
#### Job 1: Slot Generation
```
@Cron('0 2 * * *') // 02:00 UTC daily
async handleSlotGeneration()
```
- Calls: `slotGenerator.generateSlots(14)`
- Generates slots 14 days ahead
- Purpose: Keep pipeline filled
---
#### Job 2: Slot Cleanup
```
@Cron('30 2 * * *') // 02:30 UTC daily
async handleCleanupSlots()
```
- Calls: `slotGenerator.cleanupExpiredSlots()`
- Marks past OPEN slots as CLOSED
---
#### Job 3: Membership Check
```
@Cron('0 3 * * *') // 03:00 UTC daily
async handleCheckMemberships()
```
- Calls: `slotGenerator.checkExpiredMemberships()`
- Expires memberships by date or used-up sessions
---
#### Job 4: Booking Completion
```
@Cron('0 22 * * *') // 22:00 UTC daily
async handleCompleteBookings()
```
- Calls: `slotGenerator.completeBookings()`
- Marks past CONFIRMED bookings as COMPLETED
---
## 6. BookingService - Integration with TimeSlots
**Location:** `packages/server/src/booking/booking.service.ts`
### 6.1 Key Integration Points
#### `createBooking(userId: string, dto: CreateBookingDto): Promise<BookingWithRelations>`
**DTO:**
```typescript
class CreateBookingDto {
timeSlotId: string // UUID of TimeSlot
membershipId: string // UUID of Membership
}
```
**Transaction Flow:**
```
1. Fetch TimeSlot - validate status = OPEN
2. Check unique constraint - user not already booked this slot
3. Fetch Membership - validate:
- Belongs to user
- Status = ACTIVE
- Has remaining capacity:
* TIMES/TRIAL: remainingTimes > 0
* DURATION: not expired
4. Create Booking(userId, timeSlotId, membershipId) → CONFIRMED
5. Update TimeSlot:
- bookedCount++
- If bookedCount >= capacity, set status = FULL
6. Update Membership (if time-based):
- remainingTimes--
- If remainingTimes = 0, set status = USED_UP
7. Return booking with relations
```
**Error Handling:**
- TimeSlot not OPEN → BadRequestException
- Duplicate booking → ConflictException
- Invalid membership → ForbiddenException
- No remaining sessions → BadRequestException
---
#### `cancelBooking(userId: string, bookingId: string): Promise<CancelBookingResult>`
**Refund Logic:**
```
cancelHoursLimit = StudioConfig.cancelHoursLimit (default 2 hours)
slotStartMs = Date(date).setUTC Hours + startTime
deadlineMs = NOW + (cancelHoursLimit * 3600 * 1000)
withinLimit = slotStartMs >= deadlineMs
IF withinLimit:
Restore membership.remainingTimes++
ELSE:
No refund
```
**Transaction Flow:**
```
1. Mark Booking → CANCELLED, set cancelledAt
2. Decrement TimeSlot.bookedCount
3. If slot was FULL, restore to OPEN
4. If within cancel window:
- For TIMES/TRIAL: increment remainingTimes
- Restore membership status if was USED_UP
```
---
#### `getMyBookings(userId: string, status?, page, limit): Promise<PaginatedResult>`
Lists user's bookings with pagination, optionally filtered by status.
---
#### `getUpcomingBookings(userId: string): Promise<BookingWithRelations[]>`
Returns all CONFIRMED bookings for dates >= today, ordered by date.
---
## 7. Data Flow Diagrams
### 7.1 Slot Generation Flow
```
Daily 02:00 UTC
SchedulerService.handleSlotGeneration()
SlotGeneratorService.generateSlots(14)
1. Query WeekTemplate (isActive=true)
2. For next 14 days:
- Match templates by ISO weekday
- Create TimeSlot entries
3. Use createMany(skipDuplicates: true)
Database: Insert new TimeSlot records
Return: count of new slots
```
---
### 7.2 Booking Flow
```
User Action
POST /booking
timeSlotId: UUID
membershipId: UUID
BookingService.createBooking()
START TRANSACTION
├─ Validate TimeSlot (status=OPEN)
├─ Check unique(userId, timeSlotId)
├─ Validate Membership (ACTIVE, not expired)
├─ CREATE Booking(CONFIRMED)
├─ UPDATE TimeSlot(bookedCount++, status=?)
└─ UPDATE Membership(remainingTimes--)
COMMIT
Return: BookingWithRelations
```
---
### 7.3 Cancellation Flow
```
User Action
PUT /booking/:id/cancel
BookingService.cancelBooking()
Check: Now vs Slot Time + cancelHoursLimit
START TRANSACTION
├─ UPDATE Booking(CANCELLED, cancelledAt=NOW)
├─ UPDATE TimeSlot(bookedCount--, status=?)
└─ IF within cancel window:
└─ UPDATE Membership(remainingTimes++)
COMMIT
Return: { booking, refunded: boolean }
```
---
## 8. DTOs & Request/Response
### 8.1 Time Slot DTOs
**Location:** `packages/server/src/time-slot/dto/`
#### `QuerySlotsDto`
```typescript
class QuerySlotsDto {
@IsDateString()
date!: string // Format: YYYY-MM-DD
}
```
#### `CreateManualSlotDto`
```typescript
class CreateManualSlotDto {
@IsDateString()
date!: string
@IsString()
startTime!: string
@IsString()
endTime!: string
@IsOptional()
@IsInt()
@Min(1)
capacity?: number
}
```
#### `WeekTemplateItemDto` & `UpdateWeekTemplateDto`
```typescript
class WeekTemplateItemDto {
@IsInt()
@Min(1)
@Max(7)
dayOfWeek!: number // ISO: 1=Mon, 7=Sun
@IsString()
startTime!: string
@IsString()
endTime!: string
@IsOptional()
capacity?: number
@IsOptional()
isActive?: boolean
}
class UpdateWeekTemplateDto {
@ArrayNotEmpty()
templates!: WeekTemplateItemDto[]
}
```
---
## 9. Shared Constants & Enums
**Location:** `packages/shared/src/`
### 9.1 Constants
```typescript
// constants.ts
export const DEFAULT_CANCEL_HOURS_LIMIT = 2
export const DEFAULT_SLOT_CAPACITY = 1
export const SLOT_GENERATION_DAYS = 14
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
export const WEEKDAY_LABELS = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日']
```
### 9.2 Enums
```typescript
// enums.ts
enum TimeSlotStatus {
OPEN = 'OPEN',
FULL = 'FULL',
CLOSED = 'CLOSED',
}
enum TimeSlotSource {
TEMPLATE = 'TEMPLATE',
MANUAL = 'MANUAL',
}
enum BookingStatus {
CONFIRMED = 'CONFIRMED',
CANCELLED = 'CANCELLED',
COMPLETED = 'COMPLETED',
NO_SHOW = 'NO_SHOW',
}
enum MembershipStatus {
ACTIVE = 'ACTIVE',
EXPIRED = 'EXPIRED',
USED_UP = 'USED_UP',
}
```
---
## 10. File Structure Summary
```
packages/server/src/
├── time-slot/
│ ├── __tests__/
│ │ ├── slot-generator.service.spec.ts (170 lines, comprehensive tests)
│ │ └── time-slot.service.spec.ts
│ ├── dto/
│ │ ├── query-slots.dto.ts
│ │ ├── create-manual-slot.dto.ts
│ │ └── week-template.dto.ts
│ ├── slot-generator.service.ts (172 lines, 4 key methods)
│ ├── time-slot.service.ts (142 lines)
│ ├── time-slot.controller.ts (93 lines, 2 controllers)
│ └── time-slot.module.ts
├── scheduler/
│ ├── __tests__/
│ │ └── scheduler.service.spec.ts
│ ├── scheduler.service.ts (55 lines, 4 cron jobs)
│ └── scheduler.module.ts
├── booking/
│ ├── __tests__/
│ │ └── booking.service.spec.ts
│ ├── dto/
│ │ └── create-booking.dto.ts
│ ├── booking.service.ts (367 lines)
│ ├── booking.controller.ts (82 lines)
│ └── booking.module.ts
├── prisma/
│ └── schema.prisma (205 lines, includes models)
└── app.module.ts (imports TimeSlotModule, SchedulerModule)
packages/shared/src/
├── types/
│ ├── time-slot.ts
│ └── (others)
├── constants.ts (22 lines)
├── enums.ts (47 lines)
└── index.ts
```
---
## 11. Key Architectural Patterns
### 11.1 Idempotent Slot Generation
**Problem:** If scheduler crashes or delays, slots might not be generated.
**Solution:**
- Use `createMany(skipDuplicates: true)` with unique constraint on `[date, startTime, endTime]`
- Safe to re-run multiple times
- Only inserts new combinations
---
### 11.2 Atomic Transactions
**For Booking Creation:**
- Create booking, update slot, update membership in single transaction
- All-or-nothing: ensures consistency if any step fails
**For Cancellation:**
- Cancel booking, restore slot, conditionally restore membership
- Prevents race conditions
---
### 11.3 ISO Weekday Mapping
**Problem:** JavaScript `Date.getDay()` uses 0=Sunday, but WeekTemplate uses ISO 8601 (1=Monday).
**Solution:** Helper function `toIsoWeekday()`:
```typescript
function toIsoWeekday(jsDay: number): number {
return jsDay === 0 ? 7 : jsDay
}
```
---
### 11.4 Membership Type Handling
**TIMES/TRIAL cardType:**
- Deduct `remainingTimes--` on booking
- Mark USED_UP when remainingTimes = 0
- Refund if cancelled within window
**DURATION cardType:**
- Check `expireDate` not passed
- No deduction; just check validity
- No refund on cancellation
---
## 12. Example Scenarios
### Scenario 1: Setup Studio with Mon-Fri Classes
**Admin Actions:**
```json
PUT /admin/week-template
{
"templates": [
{ "dayOfWeek": 1, "startTime": "09:00", "endTime": "10:00", "capacity": 1 },
{ "dayOfWeek": 1, "startTime": "10:30", "endTime": "11:30", "capacity": 1 },
{ "dayOfWeek": 2, "startTime": "09:00", "endTime": "10:00", "capacity": 1 },
{ "dayOfWeek": 3, "startTime": "09:00", "endTime": "10:00", "capacity": 1 },
{ "dayOfWeek": 4, "startTime": "09:00", "endTime": "10:00", "capacity": 1 },
{ "dayOfWeek": 5, "startTime": "09:00", "endTime": "10:00", "capacity": 1 },
{ "dayOfWeek": 5, "startTime": "18:00", "endTime": "19:00", "capacity": 1 }
]
}
```
**Next Day (02:00 UTC):**
- Scheduler auto-generates 14 days of slots
- Result: 14 Mon morning + 14 Mon mid-morning + 14 Tue morning + ... + 14 Fri evening
**Member Action (View Availability):**
```
GET /time-slot/available?date=2026-04-10
→ Returns all slots for April 10 (Friday)
→ Includes bookings for current user
```
---
### Scenario 2: Member Books, Then Cancels
**Member Books:**
```
POST /booking
{
"timeSlotId": "slot-123",
"membershipId": "mem-456"
}
```
**System:**
1. Validates slot is OPEN, membership is ACTIVE with remaining sessions
2. Creates Booking(CONFIRMED)
3. Increments slot.bookedCount (1 → 2)
4. If now at capacity, sets slot.status = FULL
5. Decrements membership.remainingTimes (5 → 4)
**Member Cancels (within 2-hour window):**
```
PUT /booking/booking-789/cancel
```
**System:**
1. Checks if NOW + 2 hours ≤ slot start time ✓
2. Sets booking.status = CANCELLED
3. Decrements slot.bookedCount (2 → 1)
4. If slot was FULL, restores to OPEN
5. Increments membership.remainingTimes (4 → 5) ✓ refunded
---
### Scenario 3: Membership Expires
**Overnight at 03:00 UTC:**
- Scheduler runs `handleCheckMemberships()`
- Updates all ACTIVE memberships where `expireDate < NOW` to EXPIRED
- User tries to book → BadRequestException "Membership is not active (status: EXPIRED)"
---
## 13. Testing Guide
### Key Test Files
1. **`slot-generator.service.spec.ts`** (310 lines)
- Tests slot generation from templates
- Tests weekday mapping (JS vs ISO)
- Tests cleanup and expiration logic
- Tests membership and booking expiration
2. **`time-slot.service.spec.ts`** (existing)
- Tests getAvailableSlots with user booking status
- Tests manual slot creation
3. **`booking.service.spec.ts`** (existing)
- Tests booking creation with all validations
- Tests cancellation with refund logic
---
## 14. Configuration & Environment
### Required Env Variables
```
DATABASE_URL=mysql://...
```
### Studio Config (StudioConfig table)
- `cancelHoursLimit`: Hours before slot to allow free cancellation (default 2)
### Constants (shared package)
- `SLOT_GENERATION_DAYS`: 14 (days ahead to generate)
- `DEFAULT_SLOT_CAPACITY`: 1 (private lessons)
- `DEFAULT_CANCEL_HOURS_LIMIT`: 2
---
## 15. Performance Considerations
### Database Indexes
- `TimeSlot(date)` - for date range queries
- `TimeSlot(status)` - for status filtering
- `Booking(userId)` - for user's bookings
- `Booking(status)` - for status filtering
### Batch Operations
- Slot generation uses `createMany()` for efficiency
- Expiration checks use `updateMany()` instead of loops
### Transaction Isolation
- All booking/cancellation operations wrapped in transactions
- Prevents race conditions on bookedCount and remainingTimes
---
## 16. Security Notes
### Authorization
- JWT guard on all endpoints
- RolesGuard for admin endpoints (only ADMIN role)
- Users can only modify their own bookings/memberships
### Validation
- All DTOs have class-validator decorators
- UUID validation on foreign keys
- Date string validation (YYYY-MM-DD format)
### Data Integrity
- Unique constraint on `[userId, timeSlotId]` prevents duplicate bookings
- Unique constraint on `[date, startTime, endTime]` prevents duplicate slots
- Foreign key constraints on relations
---
## 17. Future Enhancement Ideas
1. **Overbooking Buffer:**
- Allow configurable overbooking ratio (e.g., 110% capacity)
2. **Waitlist Support:**
- Add BookingStatus.WAITLISTED
- Auto-promote when slot opens
3. **Recurring Cancellation:**
- Cancel all future bookings of a series
- Batch refunds
4. **Slot Availability Notifications:**
- Alert users when slots available
- Implement notification queue
5. **Dynamic Pricing:**
- Peak vs off-peak pricing
- Last-minute discounts
---
## Summary
This time-slot and scheduling system is well-architected with:
**Idempotent slot generation** - Safe to re-run
**Atomic transactions** - ACID compliance for bookings
**Automatic maintenance** - 4 daily cron jobs
**Flexible membership types** - TIMES, DURATION, TRIAL
**Refund policy** - Configurable cancellation window
**ISO weekday standard** - Proper international support
**Comprehensive validation** - DTOs with decorators
**Role-based access** - Admin vs member endpoints
The system handles:
- Auto-generating 14 days of slots nightly
- Accepting bookings with capacity management
- Canceling with conditional refunds
- Expiring memberships and marking past bookings
- All with transactional integrity and concurrent safety.

View File

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

View File

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

View File

@@ -8,25 +8,25 @@
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"@dcloudio/uni-app": "3.0.0-4060620250520001",
"@dcloudio/uni-app-plus": "3.0.0-4060620250520001",
"@dcloudio/uni-components": "3.0.0-4060620250520001",
"@dcloudio/uni-h5": "3.0.0-4060620250520001",
"@dcloudio/uni-mp-weixin": "3.0.0-4060620250520001",
"@dcloudio/uni-app": "3.0.0-5000620260331001",
"@dcloudio/uni-app-plus": "3.0.0-5000620260331001",
"@dcloudio/uni-components": "3.0.0-5000620260331001",
"@dcloudio/uni-h5": "3.0.0-5000620260331001",
"@dcloudio/uni-mp-weixin": "3.0.0-5000620260331001",
"@mp-pilates/shared": "workspace:*",
"pinia": "^2.1.7",
"vue": "^3.4.0"
},
"devDependencies": {
"@dcloudio/types": "^3.4.0",
"@dcloudio/uni-automator": "3.0.0-4060620250520001",
"@dcloudio/uni-cli-shared": "3.0.0-4060620250520001",
"@dcloudio/uni-stacktracey": "3.0.0-4060620250520001",
"@dcloudio/vite-plugin-uni": "3.0.0-4060620250520001",
"@dcloudio/uni-automator": "3.0.0-5000620260331001",
"@dcloudio/uni-cli-shared": "3.0.0-5000620260331001",
"@dcloudio/uni-stacktracey": "3.0.0-5000620260331001",
"@dcloudio/vite-plugin-uni": "3.0.0-5000620260331001",
"@types/node": "^20.0.0",
"sass": "^1.77.0",
"typescript": "^5.4.0",
"vite": "^5.4.0",
"vue-tsc": "^2.0.0",
"sass": "^1.77.0"
"vue-tsc": "^2.0.0"
}
}

View File

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

View File

@@ -15,18 +15,18 @@
<view class="info-section">
<view class="info-row">
<text class="info-label">日期</text>
<text class="info-value">{{ slot?.date }}</text>
<text class="info-value">{{ timeSlot?.date }}</text>
</view>
<view class="info-row">
<text class="info-label">时间</text>
<text class="info-value" v-if="slot">
{{ slot.startTime.slice(0, 5) }} - {{ slot.endTime.slice(0, 5) }}
<text class="info-value" v-if="timeSlot">
{{ timeSlot.startTime.slice(0, 5) }} - {{ timeSlot.endTime.slice(0, 5) }}
</text>
</view>
<view class="info-row">
<text class="info-label">剩余</text>
<text class="info-value" v-if="slot">
{{ slot.capacity - slot.bookedCount }} 个名额
<text class="info-value" v-if="timeSlot">
{{ timeSlot.capacity - timeSlot.bookedCount }} 个名额
</text>
</view>
</view>
@@ -105,13 +105,13 @@
<view class="btn-outline" @tap="handleCancel">
<text class="btn-outline-text">取消</text>
</view>
<view
<button
class="btn-confirm"
:class="{ disabled: !selectedMembershipId }"
@tap="handleConfirm"
>
<text class="btn-confirm-text">确认预约</text>
</view>
</button>
</view>
</view>
</view>
@@ -120,10 +120,11 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pilates/shared'
import { requestBookingCreatedSubscriptionMessage } from '../utils/wechat-subscription'
const props = defineProps<{
visible: boolean
slot: TimeSlotWithBookingStatus | null
timeSlot: TimeSlotWithBookingStatus | null
memberships: MembershipWithCardType[]
}>()
@@ -134,6 +135,7 @@ const emit = defineEmits<{
}>()
const selectedMembershipId = ref<string>('')
const requestingSubscribe = ref(false)
// Auto-select the first membership when popup opens or memberships list changes
watch(
@@ -150,10 +152,24 @@ const selectedMembership = computed(() =>
props.memberships.find((m) => m.id === selectedMembershipId.value) ?? null,
)
function handleConfirm() {
if (!props.slot || !selectedMembershipId.value) return
async function handleConfirm() {
if (!props.timeSlot || !selectedMembershipId.value) return
if (requestingSubscribe.value) return
requestingSubscribe.value = true
try {
await requestBookingCreatedSubscriptionMessage()
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '订阅消息授权失败'
uni.showToast({ title: message, icon: 'none' })
return
} finally {
requestingSubscribe.value = false
}
emit('confirm', {
timeSlotId: props.slot.id,
timeSlotId: props.timeSlot.id,
membershipId: selectedMembershipId.value,
})
}
@@ -212,7 +228,7 @@ function handleMaskTap() {
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
background: $primary-selected-bg;
border-radius: 50%;
}
@@ -250,7 +266,7 @@ function handleMaskTap() {
.divider {
height: 1rpx;
background: #f0f0f0;
background: $primary-border;
margin: 8rpx 0 28rpx;
}
@@ -285,14 +301,14 @@ function handleMaskTap() {
align-items: center;
padding: 24rpx 20rpx;
border-radius: 16rpx;
border: 2rpx solid #f0f0f0;
background: #fafafa;
border: 2rpx solid $primary-border;
background: $primary-bg;
gap: 20rpx;
transition: border-color 0.15s, background 0.15s;
&.selected {
border-color: #c9a87c;
background: #fffbf5;
border-color: $primary-dark;
background: $primary-selected-bg;
}
}
@@ -300,7 +316,7 @@ function handleMaskTap() {
width: 60rpx;
height: 60rpx;
border-radius: 14rpx;
background: linear-gradient(135deg, #d4b896, #c9a87c);
background: linear-gradient(135deg, $primary-color, $primary-dark);
display: flex;
align-items: center;
justify-content: center;
@@ -333,7 +349,7 @@ function handleMaskTap() {
width: 44rpx;
height: 44rpx;
border-radius: 50%;
background: #c9a87c;
background: $primary-dark;
display: flex;
align-items: center;
justify-content: center;
@@ -358,7 +374,7 @@ function handleMaskTap() {
/* Deduction tip */
.deduction-tip {
background: #fffbf0;
background: $primary-selected-bg;
border-radius: 12rpx;
padding: 16rpx 20rpx;
margin-bottom: 28rpx;
@@ -366,7 +382,7 @@ function handleMaskTap() {
.deduction-text {
font-size: 24rpx;
color: #c9a87c;
color: $primary-dark;
line-height: 1.5;
}
@@ -382,7 +398,7 @@ function handleMaskTap() {
flex: 1;
height: 88rpx;
border-radius: 44rpx;
border: 2rpx solid #e0e0e0;
border: 2rpx solid $primary-border;
display: flex;
align-items: center;
justify-content: center;
@@ -402,7 +418,7 @@ function handleMaskTap() {
flex: 2;
height: 88rpx;
border-radius: 44rpx;
background: #c9a87c;
background: linear-gradient(135deg, $primary-color, $primary-dark);
display: flex;
align-items: center;
justify-content: center;
@@ -412,7 +428,7 @@ function handleMaskTap() {
}
&.disabled {
background: #e0e0e0;
background: $primary-border;
}
}

View File

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

View File

@@ -13,7 +13,7 @@
:key="i"
class="card-row skeleton-row"
>
<view class="skeleton-thumb" />
<view class="skeleton-card-cover" />
<view class="skeleton-info">
<view class="skeleton-line skeleton-line--title" />
<view class="skeleton-line skeleton-line--sub" />
@@ -30,30 +30,45 @@
class="card-row"
@tap="goToDetail(card.id)"
>
<!-- Thumbnail -->
<view class="card-thumb" :class="thumbClass(card)">
<view class="thumb-fallback">
<text class="thumb-name">{{ truncate(card.name, 8) }}</text>
<text class="thumb-price">¥{{ formatPrice(card.price) }}</text>
<!-- Card Cover image if available, gradient fallback -->
<view class="card-cover" :class="card.coverUrl ? '' : getCardCoverClass(card.type)">
<image
v-if="card.coverUrl"
class="card-cover-img"
:src="card.coverUrl"
mode="aspectFill"
/>
<template v-else>
<view class="cover-deco cover-deco--1" />
<view class="cover-deco cover-deco--2" />
</template>
</view>
<!-- Card info aligns with card-cover height -->
<view class="card-info">
<view class="info-top">
<text class="card-name">{{ card.name }}</text>
<text class="card-validity">有效期 {{ card.durationDays }} </text>
</view>
<view class="info-bottom">
<view v-if="card.totalTimes" class="card-times">
<text class="card-times-value">{{ card.totalTimes }}</text>
<text class="card-times-unit">课时</text>
</view>
<view class="price-row">
<text class="price-current">¥{{ formatPrice(card.price) }}</text>
<text
v-if="card.originalPrice && card.originalPrice > card.price"
class="price-original"
>
¥{{ formatPrice(card.originalPrice) }}
</text>
</view>
</view>
</view>
<!-- Card info -->
<view class="card-info">
<text class="card-name">{{ card.name }}</text>
<text class="card-validity">有效期:{{ card.durationDays }} </text>
<view class="price-row">
<text class="price-label">价格:</text>
<text class="price-symbol">¥</text>
<text class="price-current">{{ formatPrice(card.price) }}</text>
<text
v-if="card.originalPrice && card.originalPrice > card.price"
class="price-original"
>
原价:¥{{ formatPrice(card.originalPrice) }}
</text>
</view>
</view>
<!-- Arrow -->
<text class="card-arrow"></text>
</view>
</view>
@@ -67,22 +82,30 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import type { CardType } from '@mp-pilates/shared'
import { CardTypeCategory } from '@mp-pilates/shared'
import { get } from '../utils/request'
import { formatPrice } from '../utils/format'
import { formatPrice, getCardCoverClass } from '../utils/format'
const cardTypes = ref<CardType[]>([])
const loading = ref(false)
const hasLoaded = ref(false)
async function fetchCardTypes() {
loading.value = true
// Stale-While-Revalidate: only show skeleton on first load
// Subsequent refreshes silently update data in background
if (!hasLoaded.value) {
loading.value = true
}
try {
const result = await get<CardType[]>('/membership/card-types')
cardTypes.value = result
.filter((c) => c.isActive)
.sort((a, b) => a.sortOrder - b.sortOrder)
hasLoaded.value = true
} catch {
uni.showToast({ title: '加载会员卡失败', icon: 'none' })
// Only show error toast on first load; silent fail on background refresh
if (!hasLoaded.value) {
uni.showToast({ title: '加载会员卡失败', icon: 'none' })
}
} finally {
loading.value = false
}
@@ -98,26 +121,18 @@ function goToDetail(id: string) {
}
function goToAllCards() {
// Navigate to all cards page or scroll behavior
uni.navigateTo({ url: '/pages/card/detail?showAll=1' })
}
function thumbClass(card: CardType): string {
if (card.type === CardTypeCategory.TRIAL) return 'thumb--trial'
if (card.type === CardTypeCategory.DURATION) return 'thumb--duration'
return 'thumb--times'
}
function truncate(str: string, maxLen: number): string {
return str.length > maxLen ? str.slice(0, maxLen) + '…' : str
}
</script>
<style lang="scss" scoped>
.card-shop {
background: #ffffff;
margin-top: 16rpx;
margin: 16rpx 24rpx 0;
padding-bottom: 20rpx;
border-radius: 20rpx;
overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
}
/* ── Section header ── */
@@ -150,132 +165,170 @@ function truncate(str: string, maxLen: number): string {
.card-row {
display: flex;
align-items: center;
gap: 24rpx;
padding: 24rpx 0;
border-bottom: 1rpx solid #f0f0f0;
gap: 20rpx;
padding: 16rpx 0;
border-bottom: 1rpx solid rgba($brand-color, 0.08);
&:last-child {
border-bottom: none;
}
}
/* ── Thumbnail ── */
.card-thumb {
/* ══════════════════════════════════════════════════════════
CARD COVER — Clean minimal design
══════════════════════════════════════════════════════════ */
.card-cover {
width: 200rpx;
height: 140rpx;
border-radius: 12rpx;
height: 130rpx;
border-radius: 16rpx;
overflow: hidden;
flex-shrink: 0;
position: relative;
}
.thumb-fallback {
.card-cover-img {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8rpx;
padding: 12rpx;
}
.thumb--times .thumb-fallback {
background: linear-gradient(135deg, #3a3a3a, #555);
/* Decorative circles */
.cover-deco {
position: absolute;
border-radius: 50%;
pointer-events: none;
&--1 {
width: 100rpx;
height: 100rpx;
top: -30rpx;
right: -20rpx;
background: rgba(255, 255, 255, 0.4);
}
&--2 {
width: 70rpx;
height: 70rpx;
bottom: -20rpx;
left: -10rpx;
background: rgba(255, 255, 255, 0.25);
}
}
.thumb--duration .thumb-fallback {
background: linear-gradient(135deg, #6c3483, #9b59b6);
/* Card cover backgrounds */
.cover--times {
background: linear-gradient(135deg, #E8D5C4 0%, #D4BFA8 100%);
}
.thumb--trial .thumb-fallback {
background: linear-gradient(135deg, #7d6608, #c9a87c);
.cover--duration {
background: linear-gradient(135deg, #D8C8DC 0%, #C4AECB 100%);
}
.thumb-name {
font-size: 22rpx;
font-weight: 600;
color: #ffffff;
text-align: center;
line-height: 1.3;
word-break: break-all;
.cover--trial {
background: linear-gradient(135deg, #C8D8D2 0%, #A9C4BC 100%);
}
.thumb-price {
font-size: 24rpx;
font-weight: 700;
color: #ffffff;
}
/* ── Card info ── */
/* ── Card info — matches card-cover height ── */
.card-info {
flex: 1;
min-width: 0;
height: 130rpx;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.info-top {
display: flex;
flex-direction: column;
justify-content: center;
gap: 4rpx;
}
.info-bottom {
display: flex;
align-items: baseline;
gap: 16rpx;
}
.card-name {
display: block;
font-size: 30rpx;
font-weight: 600;
color: #222;
margin-bottom: 8rpx;
font-weight: 700;
color: $text-primary;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
letter-spacing: 0.5rpx;
line-height: 1.2;
}
.card-validity {
display: block;
font-size: 24rpx;
color: #999;
margin-bottom: 12rpx;
font-size: 23rpx;
color: $text-secondary;
line-height: 1.2;
}
.price-row {
.card-times {
display: flex;
align-items: baseline;
gap: 4rpx;
}
.price-label {
font-size: 24rpx;
color: #e53935;
.card-times-value {
font-size: 34rpx;
font-weight: 800;
color: $brand-color;
line-height: 1;
}
.price-symbol {
font-size: 24rpx;
color: #e53935;
font-weight: 600;
.card-times-unit {
font-size: 20rpx;
color: $text-secondary;
font-weight: 500;
}
.price-row {
display: flex;
align-items: baseline;
gap: 6rpx;
}
.price-current {
font-size: 40rpx;
font-size: 32rpx;
font-weight: 800;
color: #e53935;
color: $brand-color;
line-height: 1;
}
.price-original {
font-size: 22rpx;
color: #bbb;
font-size: 20rpx;
color: $text-hint;
text-decoration: line-through;
margin-left: 12rpx;
}
/* Arrow */
.card-arrow {
font-size: 44rpx;
color: $text-hint;
flex-shrink: 0;
transform: scaleX(0.5);
transform-origin: center;
}
/* ── Skeleton ── */
.skeleton-row {
display: flex;
align-items: center;
gap: 24rpx;
padding: 24rpx 0;
border-bottom: 1rpx solid #f0f0f0;
gap: 20rpx;
padding: 20rpx 0;
border-bottom: 1rpx solid rgba($brand-color, 0.08);
}
.skeleton-thumb {
width: 200rpx;
height: 140rpx;
border-radius: 12rpx;
.skeleton-card-cover {
width: 240rpx;
height: 130rpx;
border-radius: 16rpx;
flex-shrink: 0;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
@@ -290,12 +343,12 @@ function truncate(str: string, maxLen: number): string {
.skeleton-line {
height: 24rpx;
border-radius: 6rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
&--title {
width: 70%;
width: 60%;
height: 30rpx;
}
@@ -304,16 +357,11 @@ function truncate(str: string, maxLen: number): string {
}
&--price {
width: 50%;
width: 45%;
height: 36rpx;
}
}
@keyframes shimmer {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}
/* ── Empty state ── */
.empty-state {
padding: 80rpx;
@@ -324,6 +372,6 @@ function truncate(str: string, maxLen: number): string {
.empty-text {
font-size: 28rpx;
color: #bbb;
color: $text-hint;
}
</style>

View File

@@ -0,0 +1,119 @@
<template>
<view
class="nav-bar"
:class="{ 'nav-bar--transparent': transparent }"
:style="{ paddingTop: statusBarHeight + 'px' }"
>
<view class="nav-bar__inner">
<!-- Back button -->
<view v-if="showBack" class="nav-bar__left" @tap="handleBack">
<text class="nav-bar__back-icon"></text>
</view>
<view v-else class="nav-bar__left" />
<!-- Title -->
<text class="nav-bar__title">{{ title }}</text>
<!-- Right placeholder (balances the back button) -->
<view class="nav-bar__right" />
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
defineProps<{
title: string
/** Transparent bg with white text — for pages with colored header */
transparent?: boolean
/** Show back arrow (for sub-pages navigated via navigateTo) */
showBack?: boolean
}>()
const statusBarHeight = ref(0)
onMounted(() => {
const windowInfo = uni.getWindowInfo()
statusBarHeight.value = windowInfo.statusBarHeight ?? 20
})
function handleBack() {
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack({ delta: 1 })
return
}
uni.switchTab({ url: '/pages/home/index' })
}
</script>
<style lang="scss" scoped>
.nav-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 101;
background: #ffffff;
&--transparent {
background: transparent;
.nav-bar__title {
color: #ffffff;
}
.nav-bar__back-icon {
color: #ffffff;
}
}
&__inner {
display: flex;
align-items: center;
justify-content: space-between;
height: 88rpx;
padding: 0 24rpx;
}
&__left,
&__right {
width: 72rpx;
height: 88rpx;
display: flex;
align-items: center;
flex-shrink: 0;
}
&__left {
justify-content: flex-start;
}
&__right {
justify-content: flex-end;
}
&__back-icon {
font-size: 52rpx;
font-weight: 300;
color: #1a1a2e;
line-height: 1;
margin-top: -4rpx;
}
&__title {
flex: 1;
text-align: center;
font-size: 34rpx;
font-weight: 600;
color: #1a1a2e;
letter-spacing: 2rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
</style>

View File

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

View File

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

View File

@@ -13,9 +13,13 @@
hover-stay-time="150"
@tap="handleTap(item)"
>
<view class="profile-menu__icon-wrap" :class="{ 'profile-menu__icon-wrap--admin': item.isAdmin }">
<text class="profile-menu__icon">{{ item.icon }}</text>
</view>
<view
class="profile-menu__icon-wrap"
:class="[
`profile-menu__icon-wrap--${item.key}`,
{ 'profile-menu__icon-wrap--admin': item.isAdmin },
]"
/>
<text class="profile-menu__title" :class="{ 'profile-menu__title--admin': item.isAdmin }">
{{ item.title }}
</text>
@@ -32,48 +36,74 @@ import { computed } from 'vue'
interface MenuItem {
key: string
type: 'item' | 'separator'
icon?: string
title?: string
path?: string
isAdmin?: boolean
badge?: string
action?: 'clear' | 'about'
action?: 'clear'
requireAuth?: boolean
}
const props = defineProps<{
isAdmin: boolean
requireAuth?: boolean
activeMembershipCount?: number
upcomingBookingCount?: number
inviteShareEligible?: boolean
}>()
const emit = defineEmits<{
(e: 'clear-cache'): void
(e: 'about'): void
(e: 'require-login'): void
}>()
const menuItems = computed<MenuItem[]>(() => {
const membershipBadge = props.activeMembershipCount && props.activeMembershipCount > 0
? `${props.activeMembershipCount}`
: undefined
const bookingBadge = props.upcomingBookingCount && props.upcomingBookingCount > 0
? `${props.upcomingBookingCount}`
: undefined
const items: MenuItem[] = [
{
key: 'membership',
type: 'item',
icon: '💳',
title: '我的会员卡',
path: '/pages/profile/membership',
badge: membershipBadge,
requireAuth: true,
},
{
key: 'bookings',
type: 'item',
icon: '📅',
title: '我的预约',
path: '/pages/profile/bookings',
badge: bookingBadge,
requireAuth: true,
},
...(props.isAdmin
? [{
key: 'teaching-schedule',
type: 'item' as const,
title: '我的课表',
path: '/pages/profile/teaching-schedule',
requireAuth: true,
}]
: []),
// 临时隐藏邀请好友入口,后续恢复时直接取消这段注释即可。
// ...(props.inviteShareEligible
// ? [{
// key: 'invite',
// type: 'item' as const,
// title: '邀请好友',
// path: '/pages/profile/invite',
// requireAuth: true,
// }]
// : []),
{
key: 'info',
type: 'item',
icon: '👤',
title: '个人信息',
path: '/pages/profile/info',
requireAuth: true,
@@ -85,17 +115,9 @@ const menuItems = computed<MenuItem[]>(() => {
{
key: 'clear',
type: 'item',
icon: '🗑️',
title: '清除缓存',
action: 'clear',
},
{
key: 'about',
type: 'item',
icon: '',
title: '关于我们',
action: 'about',
},
]
if (props.isAdmin) {
@@ -103,7 +125,6 @@ const menuItems = computed<MenuItem[]>(() => {
items.push({
key: 'admin',
type: 'item',
icon: '⚙️',
title: '管理中心',
path: '/pages/admin/index',
isAdmin: true,
@@ -121,8 +142,6 @@ function handleTap(item: MenuItem) {
}
if (item.action === 'clear') {
emit('clear-cache')
} else if (item.action === 'about') {
emit('about')
} else if (item.path) {
uni.navigateTo({ url: item.path })
}
@@ -163,26 +182,205 @@ function handleTap(item: MenuItem) {
}
&__icon-wrap {
width: 64rpx;
height: 64rpx;
border-radius: $radius-sm;
background: rgba($brand-color, 0.08);
width: 56rpx;
height: 56rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-right: $spacing-md;
position: relative;
background: rgba($brand-color, 0.06);
// ─── Pure CSS Icons ────────────────────────────────
// 会员卡 — 圆角矩形卡片 + 横线
&--membership {
background: rgba($accent-color, 0.10);
&::before {
content: '';
width: 26rpx;
height: 18rpx;
border: 2.5rpx solid $accent-color;
border-radius: 4rpx;
box-sizing: border-box;
}
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, 1rpx);
width: 16rpx;
height: 0;
border-top: 2.5rpx solid $accent-color;
}
}
// 预约 — 日历(矩形 + 顶部两个小竖线)
&--bookings {
background: rgba($brand-color, 0.06);
&::before {
content: '';
width: 24rpx;
height: 22rpx;
border: 2.5rpx solid $brand-color;
border-radius: 4rpx;
border-top-width: 5rpx;
box-sizing: border-box;
}
&::after {
content: '';
position: absolute;
top: 14rpx;
left: 50%;
transform: translateX(-50%);
width: 10rpx;
height: 0;
border-top: 2.5rpx solid $brand-color;
// 用 box-shadow 模拟两个竖线
box-shadow:
-4rpx -7rpx 0 0 $brand-color,
4rpx -7rpx 0 0 $brand-color;
}
}
&--teaching-schedule {
background: rgba(93, 140, 138, 0.12);
&::before {
content: '';
width: 24rpx;
height: 22rpx;
border: 2.5rpx solid #476d72;
border-radius: 6rpx;
box-sizing: border-box;
}
&::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 12rpx;
height: 12rpx;
transform: translate(-30%, -18%) rotate(45deg);
border-top: 2.5rpx solid #476d72;
border-left: 2.5rpx solid #476d72;
}
}
&--invite {
background: rgba(255, 122, 69, 0.12);
&::before {
content: '';
width: 20rpx;
height: 20rpx;
border: 2.5rpx solid #ff7a45;
border-radius: 50%;
position: absolute;
top: 12rpx;
left: 14rpx;
box-sizing: border-box;
box-shadow: 16rpx 8rpx 0 -2rpx rgba(255, 122, 69, 0.95);
}
&::after {
content: '';
position: absolute;
width: 22rpx;
height: 12rpx;
border: 2.5rpx solid #ff7a45;
border-top: none;
border-radius: 0 0 14rpx 14rpx;
left: 17rpx;
bottom: 13rpx;
box-sizing: border-box;
}
}
// 个人信息 — 人形(圆 + 肩弧)
&--info {
background: rgba($brand-color, 0.06);
&::before {
content: '';
width: 12rpx;
height: 12rpx;
border: 2.5rpx solid $brand-color;
border-radius: 50%;
position: absolute;
top: 16rpx;
left: 50%;
transform: translateX(-50%);
box-sizing: border-box;
}
&::after {
content: '';
width: 22rpx;
height: 10rpx;
border: 2.5rpx solid $brand-color;
border-bottom: none;
border-radius: 12rpx 12rpx 0 0;
position: absolute;
bottom: 13rpx;
left: 50%;
transform: translateX(-50%);
box-sizing: border-box;
}
}
// 清除缓存 — 旋转的刷新箭头(圆弧)
&--clear {
background: rgba($text-hint, 0.08);
&::before {
content: '';
width: 20rpx;
height: 20rpx;
border: 2.5rpx solid $text-secondary;
border-radius: 50%;
border-right-color: transparent;
box-sizing: border-box;
}
&::after {
content: '';
position: absolute;
top: 14rpx;
right: 15rpx;
width: 0;
height: 0;
border-left: 5rpx solid $text-secondary;
border-top: 4rpx solid transparent;
border-bottom: 4rpx solid transparent;
}
}
// 管理中心 — 齿轮(圆 + 四个刻度)
&--admin {
background: rgba($accent-color, 0.12);
&::before {
content: '';
width: 14rpx;
height: 14rpx;
border: 2.5rpx solid $accent-color;
border-radius: 50%;
box-sizing: border-box;
}
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 24rpx;
height: 24rpx;
transform: translate(-50%, -50%);
// 四条刻度线用 box-shadow 实现
background:
linear-gradient($accent-color, $accent-color) center top / 2.5rpx 5rpx no-repeat,
linear-gradient($accent-color, $accent-color) center bottom / 2.5rpx 5rpx no-repeat,
linear-gradient($accent-color, $accent-color) left center / 5rpx 2.5rpx no-repeat,
linear-gradient($accent-color, $accent-color) right center / 5rpx 2.5rpx no-repeat;
}
}
}
&__icon {
font-size: 32rpx;
line-height: 1;
}
&__title {
flex: 1;
font-size: 30rpx;
@@ -196,11 +394,17 @@ function handleTap(item: MenuItem) {
&__badge {
font-size: 22rpx;
color: #ffffff;
background: $error-color;
border-radius: 20rpx;
padding: 2rpx 12rpx;
line-height: 1;
font-weight: 600;
color: #8f6759;
background: linear-gradient(135deg, rgba(255, 248, 244, 0.98), rgba(241, 228, 220, 0.96));
border-radius: 999rpx;
padding: 9rpx 18rpx;
margin-right: $spacing-sm;
border: 1rpx solid rgba(192, 154, 137, 0.16);
box-shadow:
inset 0 1rpx 0 rgba(255, 255, 255, 0.92),
0 6rpx 16rpx rgba(143, 103, 89, 0.12);
}
&__arrow {

View File

@@ -1,88 +1,54 @@
<template>
<view class="quick-entry">
<!-- Not logged in -->
<view v-if="!userStore.loggedIn" class="entry-card login-card" @tap="handleLogin">
<view class="entry-content">
<view class="entry-left">
<text class="entry-icon">👋</text>
<view>
<text class="entry-title">欢迎来到工作室</text>
<text class="entry-subtitle">登录后即可预约课程</text>
</view>
</view>
<view class="entry-btn login-btn">
<text class="entry-btn-text">微信登录</text>
</view>
<view v-if="!userStore.loggedIn" class="entry-pill pill-login" @tap="handleLogin">
<view class="pill-dot dot-login" />
<text class="pill-label">欢迎来到工作室</text>
<view class="pill-action action-login">
<text class="pill-action-text">微信登录</text>
</view>
</view>
<!-- Logged in, no memberships at all new user -->
<!-- Logged in, no memberships new user -->
<view
v-else-if="userStore.loggedIn && userStore.memberships.length === 0"
class="entry-card trial-card"
class="entry-pill pill-trial"
@tap="handleTrialEntry"
>
<view class="entry-content">
<view class="entry-left">
<text class="entry-icon"></text>
<view>
<text class="entry-title">初次体验</text>
<text class="entry-subtitle">专属体验课了解普拉提</text>
</view>
</view>
<view class="entry-btn trial-btn">
<text class="entry-btn-text">预约体验课</text>
</view>
<view class="pill-tag tag-trial">体验</view>
<text class="pill-label">首次体验专属课程</text>
<view class="pill-action action-trial">
<text class="pill-action-text">预约体验课</text>
</view>
<view class="card-badge trial-badge">新会员专享</view>
</view>
<!-- Has valid active card + running low warning -->
<!-- Has valid active card -->
<template v-else-if="userStore.hasValidMembership">
<view class="entry-card active-card" @tap="handleBooking">
<view class="entry-content">
<view class="entry-left">
<text class="entry-icon">🧘</text>
<view>
<text class="entry-title">一键约课</text>
<text class="entry-subtitle">{{ activeMembershipLabel }}</text>
</view>
</view>
<view class="entry-btn book-btn">
<text class="entry-btn-text">立即预约</text>
</view>
</view>
<!-- Running low badge -->
<view v-if="isRunningLow" class="card-badge low-badge">
仅剩 {{ lowestRemainingTimes }}
<view class="entry-pill pill-active" @tap="handleBooking">
<view class="pill-dot dot-active" />
<text class="pill-label pill-label-active">{{ activeMembershipLabel }}</text>
<view class="pill-action action-book">
<text class="pill-action-text">约课</text>
</view>
</view>
<!-- Renew reminder if running low -->
<view v-if="isRunningLow" class="renew-tip" @tap="scrollToCardShop">
<text class="renew-tip-icon"></text>
<text class="renew-tip-text">课次即将用完点击续卡保持练习节奏</text>
<text class="renew-tip-action">续卡 </text>
<!-- Running low: thin accent strip -->
<view v-if="isRunningLow" class="renew-strip" @tap="scrollToCardShop">
<text class="renew-strip-text">仅剩 {{ lowestRemainingTimes }} · 续卡保持节奏</text>
<text class="renew-strip-arrow"></text>
</view>
</template>
<!-- Has memberships but none active buy card -->
<view
v-else
class="entry-card expired-card"
class="entry-pill pill-expired"
@tap="scrollToCardShop"
>
<view class="entry-content">
<view class="entry-left">
<text class="entry-icon">💳</text>
<view>
<text class="entry-title">续费会员卡</text>
<text class="entry-subtitle">您的卡已到期续卡继续练习</text>
</view>
</view>
<view class="entry-btn renew-btn">
<text class="entry-btn-text">购买会员卡</text>
</view>
<view class="pill-dot dot-expired" />
<text class="pill-label">会员卡已到期</text>
<view class="pill-action action-renew">
<text class="pill-action-text">续卡</text>
</view>
</view>
</view>
@@ -92,6 +58,7 @@
import { computed, ref } from 'vue'
import { useUserStore } from '../stores/user'
import { CardTypeCategory } from '@mp-pilates/shared'
import { getErrorMessage } from '../utils/auth'
const emit = defineEmits<{
(e: 'scroll-to-card-shop'): void
@@ -104,17 +71,15 @@ async function handleLogin() {
if (loading.value) return
loading.value = true
try {
await userStore.login()
await userStore.fetchMemberships()
} catch {
uni.showToast({ title: '登录失败,请重试', icon: 'none' })
await userStore.loginWithSetup()
} catch (err: unknown) {
uni.showToast({ title: getErrorMessage(err, '登录失败,请重试'), icon: 'none' })
} finally {
loading.value = false
}
}
function handleTrialEntry() {
// Navigate to the first TRIAL card detail page
uni.navigateTo({ url: '/pages/card/detail?trial=1' })
}
@@ -126,22 +91,20 @@ function scrollToCardShop() {
emit('scroll-to-card-shop')
}
// Computed: label for the active membership
const activeMembershipLabel = computed(() => {
const active = userStore.activeMemberships
if (!active.length) return ''
const m = active[0]
const cardName = m.cardType.name
if (m.cardType.type === CardTypeCategory.TIMES && m.remainingTimes !== null) {
return `${cardName} · 剩余 ${m.remainingTimes}`
return `${cardName} · 剩余 ${m.remainingTimes}`
}
const expire = new Date(m.expireDate)
const today = new Date()
const daysLeft = Math.ceil((expire.getTime() - today.getTime()) / 86400000)
return `${cardName} · 剩余 ${daysLeft}`
return `${cardName} · 剩余 ${daysLeft}`
})
// Check if any TIMES card has ≤ 2 remaining
const isRunningLow = computed(() => {
return userStore.activeMemberships.some(
(m) =>
@@ -165,154 +128,159 @@ const lowestRemainingTimes = computed(() => {
<style lang="scss" scoped>
.quick-entry {
margin: 24rpx 24rpx 0;
padding: 20rpx 24rpx 0;
}
.entry-card {
position: relative;
border-radius: 16rpx;
padding: 36rpx 32rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.10);
overflow: hidden;
}
.login-card {
background: linear-gradient(135deg, #1a1a2e, #2d2d5e);
}
.trial-card {
background: linear-gradient(135deg, #2d2d5e, #4a3f7a);
}
.active-card {
background: linear-gradient(135deg, #1a1a2e, #3a2a1a);
}
.expired-card {
background: linear-gradient(135deg, #4a4a4a, #2a2a2a);
}
.entry-content {
/* ── Pill base ── */
.entry-pill {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24rpx;
}
.entry-left {
display: flex;
align-items: center;
gap: 24rpx;
flex: 1;
}
.entry-icon {
font-size: 56rpx;
flex-shrink: 0;
}
.entry-title {
display: block;
font-size: 34rpx;
font-weight: 700;
color: #ffffff;
margin-bottom: 8rpx;
}
.entry-subtitle {
display: block;
font-size: 24rpx;
color: rgba(255, 255, 255, 0.65);
line-height: 1.4;
}
.entry-btn {
flex-shrink: 0;
padding: 16rpx 32rpx;
height: 80rpx;
border-radius: 40rpx;
padding: 0 8rpx 0 24rpx;
gap: 16rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
}
/* ── Pill variants ── */
.pill-login {
background: #1a1a2e;
}
.pill-trial {
background: linear-gradient(135deg, #2d2d5e 0%, #4a3f7a 100%);
}
.pill-active {
background: #ffffff;
border: 1rpx solid rgba(0, 0, 0, 0.06);
box-shadow: 0 2rpx 16rpx rgba(0, 0, 0, 0.05);
}
.pill-expired {
background: #f5f5f5;
border: 1rpx solid rgba(0, 0, 0, 0.04);
box-shadow: none;
}
/* ── Status dot ── */
.pill-dot {
width: 14rpx;
height: 14rpx;
border-radius: 50%;
flex-shrink: 0;
}
.dot-login {
background: $primary-color;
box-shadow: 0 0 8rpx rgba($primary-color, 0.6);
}
.dot-active {
background: #34c759;
box-shadow: 0 0 8rpx rgba(52, 199, 89, 0.5);
}
.dot-expired {
background: #aaa;
}
/* ── Tag (trial only) ── */
.pill-tag {
font-size: 20rpx;
font-weight: 700;
padding: 4rpx 14rpx;
border-radius: 20rpx;
flex-shrink: 0;
letter-spacing: 1rpx;
}
.tag-trial {
background: rgba(255, 215, 0, 0.25);
color: #ffd700;
}
/* ── 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;
height: 60rpx;
padding: 0 28rpx;
border-radius: 30rpx;
display: flex;
align-items: center;
justify-content: center;
}
.entry-btn-text {
font-size: 28rpx;
.pill-action-text {
font-size: 24rpx;
font-weight: 600;
white-space: nowrap;
line-height: 1;
}
.login-btn {
background: #c9a87c;
.action-login {
background: $primary-color;
.pill-action-text { color: #1a1a2e; }
}
.trial-btn {
background: #c9a87c;
.action-trial {
background: rgba(255, 215, 0, 0.2);
.pill-action-text { color: #ffd700; }
}
.book-btn {
background: #c9a87c;
.action-book {
background: #1a1a2e;
.pill-action-text { color: #fff; }
}
.renew-btn {
background: #888;
.action-renew {
background: #e0e0e0;
.pill-action-text { color: #555; }
}
.login-btn .entry-btn-text,
.trial-btn .entry-btn-text,
.book-btn .entry-btn-text,
.renew-btn .entry-btn-text {
color: #1a1a2e;
}
/* Corner badge */
.card-badge {
position: absolute;
top: 0;
right: 0;
padding: 8rpx 20rpx;
font-size: 20rpx;
font-weight: 600;
border-radius: 0 16rpx 0 16rpx;
}
.trial-badge {
background: #c9a87c;
color: #1a1a2e;
}
.low-badge {
background: #e74c3c;
color: #ffffff;
}
/* Renew tip bar */
.renew-tip {
/* ── Renew strip (running low) ── */
.renew-strip {
display: flex;
align-items: center;
gap: 12rpx;
margin-top: 16rpx;
padding: 20rpx 24rpx;
background: #fff8f0;
border-radius: 12rpx;
border: 1rpx solid #f0d9bc;
justify-content: center;
gap: 8rpx;
margin-top: 12rpx;
padding: 14rpx 24rpx;
background: linear-gradient(135deg, #FF6B35, #FF8E53);
border-radius: 24rpx;
}
.renew-tip-icon {
.renew-strip-text {
font-size: 22rpx;
font-weight: 500;
color: #fff;
letter-spacing: 0.5rpx;
}
.renew-strip-arrow {
font-size: 28rpx;
flex-shrink: 0;
}
.renew-tip-text {
flex: 1;
font-size: 24rpx;
color: #a0622a;
line-height: 1.4;
}
.renew-tip-action {
font-size: 24rpx;
color: #c9a87c;
font-weight: 600;
flex-shrink: 0;
font-weight: 700;
color: rgba(255, 255, 255, 0.8);
line-height: 1;
}
</style>

View File

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

View File

@@ -1,61 +1,78 @@
<template>
<view class="studio-info">
<!-- Horizontal photo strip -->
<scroll-view v-if="studioInfo?.photos?.length" scroll-x class="photo-strip" :show-scrollbar="false">
<view class="photo-strip-inner">
<image v-for="(photo, idx) in studioInfo.photos" :key="idx" class="strip-photo" :src="photo" mode="aspectFill"
@tap="previewPhoto(idx)" />
</view>
</scroll-view>
<!-- Address + Phone row -->
<!-- Address + Chat row -->
<view class="location-row">
<view class="location-left" @tap="handleAddressTap">
<text class="location-icon">📍</text>
<text class="location-text">
{{ studioInfo?.address || '深圳市宝安区西乡街道财富港 D 座 1203D' }}
</text>
</view>
<view class="phone-btn" @tap="handlePhoneTap">
<text class="phone-icon">📞</text>
<view class="location-icon" />
<view class="location-content">
<text class="location-label">场馆地址</text>
<text class="location-text">
{{ studioInfo?.address || '深圳市宝安区西乡街道财富港 D 座 1203D' }}
</text>
</view>
</view>
<button class="chat-btn" open-type="contact">
<view class="chat-icon" />
</button>
</view>
<!-- Horizontal gallery -->
<view class="gallery-block">
<scroll-view scroll-x class="gallery-scroll" :show-scrollbar="false" enhanced>
<view class="gallery-track">
<view
v-for="(photo, idx) in galleryPhotos"
:key="photo"
class="gallery-item"
@tap="previewPhoto(idx)"
>
<image class="gallery-image" :src="photo" mode="aspectFill" />
<view class="gallery-overlay" />
</view>
</view>
</scroll-view>
</view>
</view>
</template>
<script setup lang="ts">
import type { StudioConfig } from '@mp-pilates/shared'
import { computed } from 'vue'
import {
DEFAULT_STUDIO_GALLERY_PHOTOS,
type StudioConfig,
} from '@mp-pilates/shared'
const props = defineProps<{
studioInfo: StudioConfig | null
}>()
const galleryPhotos = computed(() => {
const photos = props.studioInfo?.photos?.filter(Boolean) ?? []
return photos.length ? photos : [...DEFAULT_STUDIO_GALLERY_PHOTOS]
})
function previewPhoto(index: number) {
if (!props.studioInfo?.photos?.length) return
uni.previewImage({
current: index,
urls: props.studioInfo.photos,
urls: galleryPhotos.value,
})
}
function handleAddressTap() {
if (!props.studioInfo) return
const latitude = props.studioInfo?.latitude ?? 22.567048
const longitude = props.studioInfo?.longitude ?? 113.867227
const address = props.studioInfo?.address || '深圳市宝安区西乡街道财富港 D 座 1203D'
const name = props.studioInfo?.name || 'Focus Core'
const { latitude, longitude, address, name } = props.studioInfo
if (latitude && longitude) {
uni.openLocation({
latitude,
longitude,
name: name || 'Focus Core',
address,
fail() {
copyAddress()
},
})
} else {
copyAddress()
}
uni.openLocation({
latitude,
longitude,
name,
address,
fail() {
copyAddress()
},
})
}
function copyAddress() {
@@ -68,43 +85,15 @@ function copyAddress() {
},
})
}
function handlePhoneTap() {
const phone = props.studioInfo?.phone
if (!phone) return
uni.makePhoneCall({
phoneNumber: phone,
fail() {
uni.showToast({ title: '拨号失败', icon: 'none' })
},
})
}
</script>
<style lang="scss" scoped>
.studio-info {
margin: 16rpx 24rpx 0;
background: #ffffff;
}
/* ── Photo strip ── */
.photo-strip {
width: 100%;
padding: 24rpx 0;
}
.photo-strip-inner {
display: flex;
flex-direction: row;
gap: 16rpx;
padding: 0 24rpx;
width: max-content;
}
.strip-photo {
width: 240rpx;
height: 160rpx;
border-radius: 12rpx;
flex-shrink: 0;
border-radius: 20rpx;
overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
}
/* ── Location row ── */
@@ -112,7 +101,7 @@ function handlePhoneTap() {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 32rpx 28rpx;
padding: 28rpx 32rpx 24rpx;
gap: 24rpx;
}
@@ -124,31 +113,154 @@ function handlePhoneTap() {
min-width: 0;
}
.location-icon {
font-size: 28rpx;
flex-shrink: 0;
.location-content {
flex: 1;
min-width: 0;
}
.location-label {
display: block;
font-size: 22rpx;
color: #b39a92;
letter-spacing: 2rpx;
margin-bottom: 6rpx;
}
.location-text {
font-size: 26rpx;
color: #666;
line-height: 1.5;
word-break: break-all;
color: #5f5955;
line-height: 1.6;
word-break: break-word;
}
.phone-btn {
/* ── Gallery ── */
.gallery-block {
padding: 6rpx 0 28rpx;
}
.gallery-scroll {
width: 100%;
}
.gallery-track {
display: flex;
gap: 14rpx;
padding: 0 32rpx;
width: max-content;
}
.gallery-item {
position: relative;
width: 192rpx;
height: 108rpx;
border-radius: 14rpx;
overflow: hidden;
flex-shrink: 0;
background: linear-gradient(135deg, #eadfd8 0%, #d5c0b4 100%);
box-shadow: 0 8rpx 18rpx rgba(124, 95, 82, 0.1);
}
.gallery-image {
width: 100%;
height: 100%;
}
.gallery-overlay {
position: absolute;
inset: 0;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.1) 0%, rgba(38, 28, 24, 0.2) 100%),
linear-gradient(135deg, rgba(255, 255, 255, 0.08) 0%, transparent 48%);
}
/* ── Icons ── */
.location-icon {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: rgba($brand-color, 0.06);
position: relative;
flex-shrink: 0;
// 定位图标 — 圆头 + 尖尾
&::before {
content: '';
position: absolute;
top: 14rpx;
left: 50%;
transform: translateX(-50%);
width: 18rpx;
height: 18rpx;
border: 2.5rpx solid $brand-color;
border-radius: 50% 50% 50% 0;
transform: translateX(-50%) rotate(-45deg);
box-sizing: border-box;
}
// 中心白点
&::after {
content: '';
position: absolute;
top: 21rpx;
left: 50%;
transform: translateX(-50%);
width: 6rpx;
height: 6rpx;
background: $brand-color;
border-radius: 50%;
box-sizing: border-box;
}
}
.chat-btn {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: #f5f5f5;
background: rgba($brand-color, 0.06);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 0;
margin: 0;
border: none;
}
.phone-icon {
font-size: 36rpx;
color: #4CAF50;
.chat-btn::after {
border: none;
}
.chat-icon {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: rgba($brand-color, 0.06);
position: relative;
// 消息气泡
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 26rpx;
height: 20rpx;
border: 2.5rpx solid $brand-color;
border-radius: 6rpx;
box-sizing: border-box;
}
// 气泡尾巴
&::after {
content: '';
position: absolute;
bottom: 12rpx;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 5rpx solid transparent;
border-right: 5rpx solid transparent;
border-top: 7rpx solid $brand-color;
}
}
</style>

View File

@@ -1,10 +1,10 @@
<template>
<view class="time-period-filter">
<view class="time-period-filter" :class="`time-period-filter--${variant}`">
<view
v-for="tab in tabs"
:key="tab.key ?? 'all'"
class="tab-item"
:class="{ active: modelValue === tab.key }"
:class="[`tab-item--${variant}`, { active: modelValue === tab.key }]"
@tap="handleChange(tab.key)"
>
<text class="tab-label">{{ tab.label }}</text>
@@ -25,14 +25,17 @@ interface Tab {
interface Props {
modelValue: PeriodKey
variant?: 'default' | 'booking'
}
defineProps<Props>()
const props = defineProps<Props>()
const emit = defineEmits<{
change: [period: PeriodKey]
'update:modelValue': [period: PeriodKey]
}>()
const variant = computed(() => props.variant ?? 'default')
const tabs = computed<Tab[]>(() => [
{ key: null, label: '全部' },
...Object.entries(TIME_PERIODS).map(([key, val]) => ({
@@ -53,7 +56,12 @@ function handleChange(key: PeriodKey) {
flex-direction: row;
background: #fff;
padding: 0 24rpx;
border-bottom: 1rpx solid #f0ece8;
border-bottom: 1rpx solid $primary-border;
&.time-period-filter--booking {
background: rgba(252, 250, 248, 0.96);
border-bottom-color: rgba(192, 154, 137, 0.12);
}
.tab-item {
flex: 1;
@@ -71,7 +79,7 @@ function handleChange(key: PeriodKey) {
&.active {
.tab-label {
color: #c9a87c;
color: $primary-dark;
font-weight: 600;
}
@@ -83,10 +91,30 @@ function handleChange(key: PeriodKey) {
transform: translateX(-50%);
width: 40rpx;
height: 4rpx;
background: #c9a87c;
background: $primary-dark;
border-radius: 2rpx;
}
}
&.tab-item--booking {
.tab-label {
color: #9d8b83;
}
&.active {
.tab-label {
color: #8f6759;
}
&::after {
width: 48rpx;
height: 5rpx;
background: linear-gradient(90deg, #c8a899, #a87d6c);
border-radius: 999rpx;
box-shadow: 0 4rpx 10rpx rgba(168, 125, 108, 0.18);
}
}
}
}
}
</style>

View File

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

View File

@@ -1,11 +1,11 @@
<template>
<view class="user-card">
<!-- Header: gradient background -->
<view class="user-card__header">
<!-- Header: gradient background, padded to sit below nav bar -->
<view class="user-card__header" :style="{ paddingTop: (navBarHeight ?? 0) + 'px' }">
<!-- Not logged in state -->
<view v-if="!loggedIn" class="user-card__guest">
<view class="user-card__avatar-wrap">
<image class="user-card__avatar-img" src="/static/default-avatar.png" mode="aspectFill" />
<image class="user-card__avatar-img" src="/static/default-avatar.jpg" mode="aspectFill" />
</view>
<view class="user-card__guest-info">
<text class="user-card__guest-title">Hi欢迎来到普拉提</text>
@@ -25,13 +25,16 @@
mode="aspectFill"
@error="onAvatarError"
/>
<view class="user-card__vip-badge" v-if="vipLevel">
<text class="user-card__vip-text">{{ vipLevel }}</text>
</view>
</view>
<view class="user-card__info">
<view class="user-card__name-row">
<text class="user-card__nickname">{{ user!.nickname }}</text>
<view v-if="hasMembership" class="user-card__member-badge">
<view class="user-card__member-icon">
<text class="user-card__member-letter">C</text>
</view>
<text class="user-card__member-label">CLUB</text>
</view>
</view>
<text v-if="maskedPhone" class="user-card__phone">{{ maskedPhone }}</text>
</view>
@@ -72,9 +75,9 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, watch } from 'vue'
import type { UserProfileResponse, UserStatsResponse, MembershipWithCardType } from '@mp-pilates/shared'
import { MembershipStatus } from '@mp-pilates/shared'
import { CardTypeCategory, MembershipStatus } from '@mp-pilates/shared'
const props = defineProps<{
loggedIn: boolean
@@ -83,6 +86,8 @@ const props = defineProps<{
stats: UserStatsResponse | null
memberships?: readonly MembershipWithCardType[]
loading?: boolean
/** Height of the custom nav bar in px, so header content starts below it */
navBarHeight?: number
}>()
const emit = defineEmits<{
@@ -91,9 +96,19 @@ const emit = defineEmits<{
const avatarFailed = ref(false)
// 头像 URL 变化时重置加载错误状态,避免新头像因偶发加载失败而被永久隐藏
watch(
() => props.user?.avatarUrl,
(newUrl, oldUrl) => {
if (newUrl && newUrl !== oldUrl) {
avatarFailed.value = false
}
},
)
const avatarSrc = computed(() => {
if (avatarFailed.value || !props.user?.avatarUrl) {
return '/static/default-avatar.png'
return '/static/default-avatar.jpg'
}
return props.user.avatarUrl
})
@@ -104,24 +119,25 @@ const maskedPhone = computed(() => {
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
})
// Derive VIP level from active memberships count
const activeMemberships = computed(() =>
props.memberships?.filter((m) => m.status === MembershipStatus.ACTIVE) ?? [],
)
const vipLevel = computed(() => {
const count = activeMemberships.value.length
if (count >= 3) return 'VIP3'
if (count >= 2) return 'VIP2'
if (count >= 1) return 'VIP1'
return null
})
const activeMembershipCount = computed(
() => props.user?.activeMembershipCount ?? activeMemberships.value.length,
)
const hasMembership = computed(() => activeMembershipCount.value > 0)
function toSafeCount(value: number | null | undefined): number {
return typeof value === 'number' && Number.isFinite(value) ? value : 0
}
// Sum remaining sessions from all active time-based memberships
const remainingSessions = computed(() =>
activeMemberships.value
.filter((m) => m.cardType.type === 'TIMES')
.reduce((sum, m) => sum + m.remainingCount, 0),
.filter((m) => m.cardType.type === CardTypeCategory.TIMES)
.reduce((sum, m) => sum + toSafeCount(m.remainingTimes), 0),
)
function onAvatarError() {
@@ -135,12 +151,12 @@ function handleLogin() {
<style lang="scss" scoped>
.user-card {
background: linear-gradient(135deg, #7c3aed 0%, #a855f7 50%, #ec4899 100%);
background: linear-gradient(160deg, #E1F4FA 0%, $primary-color 50%, $primary-dark 100%);
border-radius: 0 0 40rpx 40rpx;
overflow: hidden;
&__header {
padding: 60rpx $spacing-lg $spacing-lg;
padding: $spacing-lg $spacing-lg $spacing-lg;
}
// ── Guest state ──
@@ -207,24 +223,6 @@ function handleLogin() {
border: 4rpx solid rgba(255, 255, 255, 0.4);
}
&__vip-badge {
position: absolute;
bottom: -6rpx;
left: 50%;
transform: translateX(-50%);
background: linear-gradient(135deg, #fbbf24, #f59e0b);
border-radius: 20rpx;
padding: 2rpx 12rpx;
border: 2rpx solid #ffffff;
}
&__vip-text {
font-size: 18rpx;
font-weight: 700;
color: #7c2d12;
line-height: 1;
}
&__info {
flex: 1;
display: flex;
@@ -244,6 +242,59 @@ function handleLogin() {
color: #ffffff;
}
&__member-badge {
display: inline-flex;
align-items: center;
gap: 10rpx;
padding: 6rpx 14rpx 6rpx 8rpx;
border-radius: 999rpx;
background: linear-gradient(135deg, rgba(255, 249, 245, 0.98), rgba(242, 229, 221, 0.96));
border: 1rpx solid rgba(192, 154, 137, 0.18);
box-shadow:
inset 0 1rpx 0 rgba(255, 255, 255, 0.9),
0 8rpx 20rpx rgba(143, 103, 89, 0.14);
}
&__member-icon {
width: 34rpx;
height: 34rpx;
border-radius: 50%;
position: relative;
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(circle at 30% 30%, #fffdfb 0%, #f2e2d8 40%, #c79d89 100%);
box-shadow:
inset 0 2rpx 4rpx rgba(255, 255, 255, 0.76),
0 3rpx 8rpx rgba(143, 103, 89, 0.16);
&::after {
content: '';
position: absolute;
inset: 3rpx;
border-radius: 50%;
border: 1.5rpx solid rgba(143, 103, 89, 0.28);
}
}
&__member-letter {
position: relative;
z-index: 1;
font-size: 20rpx;
line-height: 1;
font-weight: 800;
color: #8f6759;
letter-spacing: 1rpx;
}
&__member-label {
font-size: 20rpx;
line-height: 1;
font-weight: 700;
color: #8f6759;
letter-spacing: 2rpx;
}
&__phone {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.75);

View File

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

View File

@@ -1,88 +1,132 @@
{
"easycom": {
"autoscan": true
},
"pages": [
{
"path": "pages/home/index",
"style": {
"navigationBarTitleText": "首页",
"navigationStyle": "custom"
}
},
{
"path": "pages/booking/index",
"style": {
"navigationBarTitleText": "预约课程"
"navigationStyle": "custom"
}
},
{
"path": "pages/booking/detail",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/profile/index",
"style": {
"navigationBarTitleText": "我的"
"navigationStyle": "custom"
}
},
{
"path": "pages/card/detail",
"style": {
"navigationBarTitleText": "购买会员卡"
"navigationStyle": "custom"
}
},
{
"path": "pages/profile/membership",
"style": {
"navigationBarTitleText": "我的会员卡"
"navigationStyle": "custom"
}
},
{
"path": "pages/profile/bookings",
"style": {
"navigationBarTitleText": "我的预约"
"navigationStyle": "custom"
}
},
{
"path": "pages/profile/teaching-schedule",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/profile/info",
"style": {
"navigationBarTitleText": "个人信息"
"navigationStyle": "custom"
}
},
{
"path": "pages/profile/invite",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/teacher/detail",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/admin/index",
"style": {
"navigationBarTitleText": "管理中心"
"navigationStyle": "custom"
}
},
{
"path": "pages/admin/week-template",
"path": "pages/admin/bookings",
"style": {
"navigationBarTitleText": "排课设置"
"navigationStyle": "custom"
}
},
{
"path": "pages/admin/schedule",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/admin/slot-adjust",
"style": {
"navigationBarTitleText": "时段调整"
"navigationStyle": "custom"
}
},
{
"path": "pages/admin/members",
"style": {
"navigationBarTitleText": "会员管理"
"navigationStyle": "custom"
}
},
{
"path": "pages/admin/orders",
"style": {
"navigationBarTitleText": "订单管理"
"navigationStyle": "custom"
}
},
{
"path": "pages/admin/card-types",
"style": {
"navigationBarTitleText": "卡种管理"
"navigationStyle": "custom"
}
},
{
"path": "pages/admin/studio",
"style": {
"navigationBarTitleText": "工作室设置"
"navigationStyle": "custom"
}
},
{
"path": "pages/admin/flash-sales",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/flash-sale/detail",
"style": {
"navigationStyle": "custom"
}
}
],
@@ -96,7 +140,7 @@
"color": "#999999",
"selectedColor": "#1a1a2e",
"backgroundColor": "#ffffff",
"borderStyle": "black",
"borderStyle": "white",
"list": [
{
"pagePath": "pages/home/index",

View File

@@ -0,0 +1,785 @@
<template>
<view class="admin-bookings-page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="课程管理" show-back />
<!-- Stats row -->
<view class="stats-row">
<view class="stat-item" @tap="switchTab(null)">
<text class="stat-num">{{ stats.total }}</text>
<text class="stat-label">全部</text>
</view>
<view class="stat-item stat-item--pending" @tap="switchTab('PENDING_CONFIRMATION')">
<text class="stat-num">{{ stats.pending }}</text>
<text class="stat-label">待确认</text>
</view>
<view class="stat-item stat-item--confirmed" @tap="switchTab('CONFIRMED')">
<text class="stat-num">{{ stats.confirmed }}</text>
<text class="stat-label">已确认</text>
</view>
<view class="stat-item stat-item--completed" @tap="switchTab('COMPLETED')">
<text class="stat-num">{{ stats.completed }}</text>
<text class="stat-label">已完成</text>
</view>
</view>
<!-- Tab filter bar -->
<view class="filter-bar">
<view
v-for="tab in filterTabs"
:key="tab.value ?? 'all'"
class="filter-tab"
:class="{ active: activeFilter === tab.value }"
@tap="switchTab(tab.value)"
>
<text class="filter-tab-text">{{ tab.label }}</text>
</view>
</view>
<!-- Booking list -->
<scroll-view
class="scroll"
scroll-y
refresher-enabled
:refresher-triggered="refreshing"
@refresherrefresh="onRefresh"
>
<!-- Loading -->
<view v-if="loading && !refreshing" class="loading-wrap">
<view v-for="i in 4" :key="i" class="skeleton-card">
<view class="skeleton-stripe" />
<view class="skeleton-body">
<view class="skeleton-line skeleton-line--long" />
<view class="skeleton-line skeleton-line--medium" />
<view class="skeleton-line skeleton-line--short" />
</view>
</view>
</view>
<!-- Empty -->
<view v-else-if="bookings.length === 0" class="empty-wrap">
<view class="empty-icon-circle">
<text class="empty-icon-text">📋</text>
</view>
<text class="empty-title">暂无预约</text>
<text class="empty-sub">当前筛选条件下没有预约记录</text>
</view>
<!-- Booking cards -->
<view v-else class="list">
<view
v-for="booking in bookings"
:key="booking.id"
class="booking-card"
@tap="goDetail(booking)"
>
<!-- Left stripe -->
<view class="booking-stripe" :class="bookingStatusStripeClass(booking.status)" />
<!-- Content -->
<view class="booking-content">
<!-- Header row -->
<view class="booking-header">
<view class="student-info">
<text class="student-name">{{ booking.user?.nickname || '匿名用户' }}</text>
<text v-if="booking.user?.phone" class="student-phone">{{ booking.user.phone }}</text>
</view>
<view class="status-badge" :class="bookingStatusBadgeClass(booking.status)">
<text class="status-text">{{ bookingStatusLabel(booking.status) }}</text>
</view>
</view>
<!-- Course info -->
<view class="course-info">
<text class="course-date">{{ formatDateDisplay(booking.timeSlot.date) }}</text>
<text class="course-time">{{ booking.timeSlot.startTime.slice(0, 5) }} - {{ booking.timeSlot.endTime.slice(0, 5) }}</text>
</view>
<!-- Card type -->
<view class="card-info">
<text class="card-label">使用卡种</text>
<text class="card-name">{{ booking.membership?.cardType?.name }}</text>
</view>
<!-- Action buttons -->
<view v-if="booking.status === 'PENDING_CONFIRMATION'" class="action-row">
<view class="action-btn action-btn--confirm" @tap.stop="handleConfirm(booking)">
<text class="action-btn-text">确认预约</text>
</view>
<view class="action-btn action-btn--cancel" @tap.stop="handleCancel(booking)">
<text class="action-btn-text">取消</text>
</view>
</view>
<view v-else-if="booking.status === 'CONFIRMED'" class="action-row">
<view class="action-btn action-btn--complete" @tap.stop="handleComplete(booking)">
<text class="action-btn-text">核销完成</text>
</view>
<view class="action-btn action-btn--noshow" @tap.stop="handleNoShow(booking)">
<text class="action-btn-text">标记未到</text>
</view>
</view>
<!-- Timeline preview -->
<view v-if="getHistory(booking.id).length > 0" class="timeline-preview">
<view
v-for="(h, idx) in getHistory(booking.id).slice(-2)"
:key="idx"
class="timeline-item"
>
<text class="timeline-dot" :class="bookingTimelineDotClass(h.toStatus)" />
<text class="timeline-text">{{ formatTimelineText(h) }}</text>
</view>
</view>
</view>
</view>
</view>
<!-- Load more / pagination -->
<view v-if="bookings.length > 0 && hasMore" class="load-more" @tap="loadMore">
<text class="load-more-text">加载更多</text>
</view>
<view class="scroll-bottom-spacer" />
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import type { BookingWithUser, BookingStatusHistory } from '@mp-pilates/shared'
import { BookingStatus } from '@mp-pilates/shared'
import { useBookingStore } from '../../stores/booking'
import { getSystemLayout } from '../../utils/system'
import {
formatDateDisplay,
bookingStatusLabel,
bookingStatusBadgeClass,
bookingStatusStripeClass,
bookingTimelineDotClass,
} from '../../utils/booking-helpers'
import CustomNavBar from '../../components/CustomNavBar.vue'
// ─── Store & Nav ──────────────────────────────────────────────────────────
const bookingStore = useBookingStore()
const navBarHeight = ref('64px')
const refreshing = ref(false)
const loading = ref(false)
// ─── Filter state ─────────────────────────────────────────────────────────
type FilterValue = string | null
const filterTabs: { label: string; value: FilterValue }[] = [
{ label: '全部', value: null },
{ label: '待确认', value: 'PENDING_CONFIRMATION' },
{ label: '已确认', value: 'CONFIRMED' },
{ label: '已完成', value: 'COMPLETED' },
{ label: '已取消', value: 'CANCELLED' },
]
const activeFilter = ref<FilterValue>(null)
// ─── Pagination ───────────────────────────────────────────────────────────
const currentPage = ref(1)
const pageSize = 20
const hasMore = ref(false)
const totalCount = ref(0)
// ─── Data ────────────────────────────────────────────────────────────────
const bookings = ref<BookingWithUser[]>([])
const allBookingsCache = ref<BookingWithUser[]>([]) // cache for stats
const historyMap = ref<Record<string, BookingStatusHistory[]>>({})
// ─── Computed stats ──────────────────────────────────────────────────────
const stats = computed(() => {
const cache = allBookingsCache.value
return {
total: cache.length,
pending: cache.filter((b) => b.status === BookingStatus.PENDING_CONFIRMATION).length,
confirmed: cache.filter((b) => b.status === BookingStatus.CONFIRMED).length,
completed: cache.filter(
(b) => b.status === BookingStatus.COMPLETED || b.status === BookingStatus.NO_SHOW,
).length,
}
})
// ─── Timeline helpers ─────────────────────────────────────────────────────
function getHistory(bookingId: string): BookingStatusHistory[] {
return historyMap.value[bookingId] || []
}
function formatTimelineText(h: BookingStatusHistory): string {
const d = new Date(h.createdAt)
const time = `${d.getMonth() + 1}${d.getDate()}${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
return `${time} ${h.remark || bookingStatusLabel(h.toStatus)}`
}
// ─── Data loading ─────────────────────────────────────────────────────────
async function loadBookings(append = false) {
if (loading.value) return
loading.value = true
try {
const page = append ? currentPage.value + 1 : 1
const result = await bookingStore.fetchAllAdminBookings(page, pageSize, activeFilter.value ?? undefined)
if (append) {
bookings.value = [...bookings.value, ...(result.data as BookingWithUser[])]
currentPage.value = page
} else {
bookings.value = result.data as BookingWithUser[]
currentPage.value = 1
}
totalCount.value = result.total
hasMore.value = bookings.value.length < result.total
// Fetch history for each booking
if (!append) {
await Promise.all(
bookings.value.map((b) => fetchHistory(b.id)),
)
}
// Update cache for stats
if (!append && activeFilter.value === null) {
allBookingsCache.value = bookings.value
}
} catch (err) {
console.error('Load bookings failed:', err)
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
async function fetchHistory(bookingId: string) {
try {
const history = await bookingStore.fetchBookingHistory(bookingId)
historyMap.value[bookingId] = history
} catch (err) {
console.error('Fetch history failed:', err)
}
}
async function loadAllForStats() {
try {
// Load all statuses for stats display
const result = await bookingStore.fetchAllAdminBookings(1, 200, undefined)
allBookingsCache.value = result.data as BookingWithUser[]
} catch (err) {
console.error('Load stats failed:', err)
}
}
async function onRefresh() {
refreshing.value = true
await Promise.all([loadBookings(false), loadAllForStats()])
refreshing.value = false
}
async function loadMore() {
if (!hasMore.value) return
await loadBookings(true)
}
// ─── Tab switching ───────────────────────────────────────────────────────
function switchTab(value: FilterValue) {
if (activeFilter.value === value) return
activeFilter.value = value
loadBookings(false)
}
// ─── Actions ──────────────────────────────────────────────────────────────
async function handleConfirm(booking: BookingWithUser) {
uni.showModal({
title: '确认预约',
content: `确认 ${booking.user?.nickname} 的预约?确认后将扣除会员次数。`,
confirmText: '确认',
success: async (res) => {
if (!res.confirm) return
uni.showLoading({ title: '处理中...' })
try {
await bookingStore.confirmBooking(booking.id)
uni.hideLoading()
uni.showToast({ title: '已确认', icon: 'success' })
await onRefresh()
} catch (err: unknown) {
uni.hideLoading()
const msg = err instanceof Error ? err.message : '操作失败'
uni.showToast({ title: msg, icon: 'none' })
}
},
})
}
async function handleComplete(booking: BookingWithUser) {
uni.showModal({
title: '核销完成',
content: `标记 ${booking.user?.nickname} 的课程为已完成?`,
confirmText: '确认',
success: async (res) => {
if (!res.confirm) return
uni.showLoading({ title: '处理中...' })
try {
await bookingStore.completeBooking(booking.id)
uni.hideLoading()
uni.showToast({ title: '已核销', icon: 'success' })
await onRefresh()
} catch (err: unknown) {
uni.hideLoading()
const msg = err instanceof Error ? err.message : '操作失败'
uni.showToast({ title: msg, icon: 'none' })
}
},
})
}
async function handleNoShow(booking: BookingWithUser) {
uni.showModal({
title: '标记未到',
content: `标记 ${booking.user?.nickname} 的课程为未出席?`,
confirmText: '确认',
success: async (res) => {
if (!res.confirm) return
uni.showLoading({ title: '处理中...' })
try {
await bookingStore.markNoShow(booking.id)
uni.hideLoading()
uni.showToast({ title: '已标记', icon: 'success' })
await onRefresh()
} catch (err: unknown) {
uni.hideLoading()
const msg = err instanceof Error ? err.message : '操作失败'
uni.showToast({ title: msg, icon: 'none' })
}
},
})
}
async function handleCancel(booking: BookingWithUser) {
uni.showModal({
title: '取消预约',
content: `取消 ${booking.user?.nickname} 的预约?`,
confirmText: '确认取消',
confirmColor: '#ef4444',
success: async (res) => {
if (!res.confirm) return
uni.showLoading({ title: '处理中...' })
try {
await bookingStore.cancelBooking(booking.id)
uni.hideLoading()
uni.showToast({ title: '已取消', icon: 'success' })
await onRefresh()
} catch (err: unknown) {
uni.hideLoading()
const msg = err instanceof Error ? err.message : '操作失败'
uni.showToast({ title: msg, icon: 'none' })
}
},
})
}
function goDetail(booking: BookingWithUser) {
uni.navigateTo({
url: `/pages/booking/detail?id=${booking.id}`,
})
}
// ─── Lifecycle ────────────────────────────────────────────────────────────
onMounted(() => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
loadBookings(false)
loadAllForStats()
})
</script>
<style lang="scss" scoped>
.admin-bookings-page {
min-height: 100vh;
background: $primary-bg;
display: flex;
flex-direction: column;
}
/* ── Stats row ──────────────────────────────────────── */
.stats-row {
display: flex;
background: #fff;
padding: 24rpx 16rpx;
gap: 8rpx;
border-bottom: 1rpx solid $primary-border;
}
.stat-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 6rpx;
padding: 16rpx 8rpx;
border-radius: 12rpx;
transition: background 0.15s;
&:active {
background: rgba(0, 0, 0, 0.04);
}
}
.stat-num {
font-size: 36rpx;
font-weight: 700;
color: #4A4035;
font-family: 'DIN Alternate', 'Helvetica Neue', Arial, sans-serif;
}
.stat-label {
font-size: 22rpx;
color: #A09080;
}
.stat-item--pending .stat-num { color: #f59e0b; }
.stat-item--confirmed .stat-num { color: $primary-dark; }
.stat-item--completed .stat-num { color: #66bb6a; }
/* ── Filter bar ────────────────────────────────────── */
.filter-bar {
display: flex;
background: #fff;
padding: 0 16rpx 16rpx;
gap: 8rpx;
}
.filter-tab {
padding: 10rpx 20rpx;
border-radius: 20rpx;
background: rgba(0, 0, 0, 0.04);
transition: all 0.15s;
&.active {
background: $primary-dark;
.filter-tab-text {
color: #fff;
}
}
}
.filter-tab-text {
font-size: 24rpx;
color: #666;
font-weight: 500;
}
/* ── Scroll ──────────────────────────────────────────── */
.scroll {
flex: 1;
height: calc(100vh - 300rpx);
}
/* ── Loading skeleton ────────────────────────────────── */
.loading-wrap {
padding: 24rpx;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.skeleton-card {
border-radius: 16rpx;
background: #fff;
overflow: hidden;
display: flex;
flex-direction: row;
}
.skeleton-stripe {
width: 8rpx;
flex-shrink: 0;
background: #eee;
}
.skeleton-body {
flex: 1;
padding: 28rpx 24rpx;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.skeleton-line {
height: 28rpx;
border-radius: 8rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
&--long { width: 60%; }
&--medium { width: 40%; }
&--short { width: 30%; }
}
/* ── Empty ───────────────────────────────────────────── */
.empty-wrap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 40rpx;
gap: 16rpx;
}
.empty-icon-circle {
width: 140rpx;
height: 140rpx;
border-radius: 50%;
background: $primary-border;
display: flex;
align-items: center;
justify-content: center;
}
.empty-icon-text {
font-size: 56rpx;
}
.empty-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.empty-sub {
font-size: 26rpx;
color: #999;
}
/* ── List ────────────────────────────────────────────── */
.list {
padding: 20rpx 24rpx 0;
display: flex;
flex-direction: column;
gap: 20rpx;
}
/* ── Booking card ────────────────────────────────────── */
.booking-card {
background: #fff;
border-radius: 20rpx;
overflow: hidden;
box-shadow: 0 2rpx 16rpx rgba(0, 0, 0, 0.05);
display: flex;
flex-direction: row;
}
.booking-stripe {
width: 8rpx;
flex-shrink: 0;
&.stripe--pending { background: #f59e0b; }
&.stripe--confirmed { background: $primary-dark; }
&.stripe--completed { background: #66bb6a; }
&.stripe--cancelled { background: #e0e0e0; }
&.stripe--noshow { background: #ef5350; }
}
.booking-content {
flex: 1;
padding: 24rpx 20rpx;
display: flex;
flex-direction: column;
gap: 14rpx;
}
.booking-header {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
}
.student-info {
display: flex;
flex-direction: column;
gap: 4rpx;
}
.student-name {
font-size: 30rpx;
font-weight: 600;
color: #1a1a1a;
}
.student-phone {
font-size: 24rpx;
color: #888;
}
/* Status badge */
.status-badge {
padding: 8rpx 18rpx;
border-radius: 20rpx;
flex-shrink: 0;
&.badge--pending { background: rgba(245, 158, 11, 0.12); }
&.badge--confirmed { background: rgba(201, 168, 124, 0.12); }
&.badge--completed { background: rgba(102, 187, 106, 0.12); }
&.badge--cancelled { background: rgba(0, 0, 0, 0.04); }
&.badge--noshow { background: rgba(239, 83, 80, 0.1); }
}
.status-text {
font-size: 22rpx;
font-weight: 600;
.badge--pending & { color: #f59e0b; }
.badge--confirmed & { color: $primary-dark; }
.badge--completed & { color: #66bb6a; }
.badge--cancelled & { color: #bbb; }
.badge--noshow & { color: #ef5350; }
}
/* Course info */
.course-info {
display: flex;
flex-direction: row;
align-items: center;
gap: 16rpx;
}
.course-date {
font-size: 28rpx;
font-weight: 600;
color: #333;
}
.course-time {
font-size: 26rpx;
color: #666;
}
/* Card info */
.card-info {
display: flex;
flex-direction: row;
align-items: center;
gap: 8rpx;
}
.card-label {
font-size: 22rpx;
color: #bbb;
}
.card-name {
font-size: 24rpx;
color: #666;
font-weight: 500;
}
/* Action buttons */
.action-row {
display: flex;
flex-direction: row;
gap: 12rpx;
padding-top: 8rpx;
border-top: 1rpx solid #f5f5f5;
}
.action-btn {
flex: 1;
padding: 16rpx 0;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.15s;
&:active {
opacity: 0.8;
}
&--confirm {
background: linear-gradient(135deg, $primary-color, $primary-dark);
}
&--cancel {
background: rgba(0, 0, 0, 0.04);
}
&--complete {
background: linear-gradient(135deg, #66bb6a, #4caf50);
}
&--noshow {
background: rgba(239, 83, 80, 0.1);
}
}
.action-btn-text {
font-size: 26rpx;
font-weight: 600;
color: #fff;
.action-btn--cancel & {
color: #666;
}
.action-btn--noshow & {
color: #ef5350;
}
}
/* Timeline preview */
.timeline-preview {
display: flex;
flex-direction: column;
gap: 6rpx;
padding-top: 8rpx;
border-top: 1rpx solid #f5f5f5;
}
.timeline-item {
display: flex;
flex-direction: row;
align-items: center;
gap: 8rpx;
}
.timeline-dot {
width: 8rpx;
height: 8rpx;
border-radius: 50%;
flex-shrink: 0;
&.dot--pending { background: #f59e0b; }
&.dot--confirmed { background: $primary-dark; }
&.dot--completed { background: #66bb6a; }
&.dot--cancelled { background: #e0e0e0; }
&.dot--noshow { background: #ef5350; }
}
.timeline-text {
font-size: 20rpx;
color: #999;
}
/* Load more */
.load-more {
padding: 32rpx;
display: flex;
align-items: center;
justify-content: center;
}
.load-more-text {
font-size: 26rpx;
color: $primary-dark;
font-weight: 500;
}
/* Bottom spacer */
.scroll-bottom-spacer {
height: 48rpx;
}
</style>

View File

@@ -1,6 +1,7 @@
<template>
<view class="page">
<!-- Add button -->
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="卡种管理" show-back />
<!-- Toolbar -->
<view class="toolbar">
<text class="toolbar-hint"> {{ cardTypes.length }} 个卡种</text>
<view class="add-btn" @tap="openAdd">
@@ -64,151 +65,198 @@
<!-- Actions -->
<view class="ct-actions">
<view class="ct-action-btn edit-btn" @tap="openEdit(ct)">
<view class="ct-action-btn edit-btn" @tap.stop="openEdit(ct)">
<text class="ct-action-text">编辑</text>
</view>
<view
class="ct-action-btn toggle-btn"
:class="ct.isActive ? 'toggle-off' : 'toggle-on'"
@tap="toggleActive(ct)"
@tap.stop="confirmToggle(ct)"
>
<text class="ct-action-text">{{ ct.isActive ? '下架' : '上架' }}</text>
</view>
<view class="ct-action-btn delete-btn" @tap="confirmDelete(ct)">
<view class="ct-action-btn delete-btn" @tap.stop="confirmDelete(ct)">
<text class="ct-action-text">删除</text>
</view>
</view>
</view>
</view>
<!-- Add / Edit modal -->
<view v-if="showModal" class="modal-mask" @tap.self="closeModal">
<scroll-view scroll-y class="modal">
<text class="modal-title">{{ editTarget ? '编辑卡种' : '新增卡种' }}</text>
<view class="modal-field">
<text class="modal-label">卡种名称</text>
<input
class="modal-input"
v-model="form.name"
placeholder="如10次课套餐"
placeholder-style="color:#bbb"
/>
</view>
<view class="modal-field">
<text class="modal-label">类型</text>
<picker
mode="selector"
:range="typeOptions"
range-key="label"
:value="form.typeIdx"
@change="(e: any) => form.typeIdx = Number(e.detail.value)"
>
<view class="picker-display">
<text class="picker-text">{{ typeOptions[form.typeIdx].label }}</text>
<text class="picker-arrow"></text>
<!-- 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>
</picker>
</view>
<view class="modal-field">
<text class="modal-label">现价</text>
<input
class="modal-input"
type="digit"
v-model="form.priceStr"
placeholder="如980"
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="number"
v-model="form.totalTimesStr"
placeholder="次卡必填,月卡留空"
placeholder-style="color:#bbb"
/>
</view>
<view class="modal-field">
<text class="modal-label">有效天数</text>
<input
class="modal-input"
type="number"
v-model="form.durationDaysStr"
placeholder="如90"
placeholder-style="color:#bbb"
/>
</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="200"
auto-height
/>
</view>
<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>
<!-- Form fields -->
<view class="modal-body">
<view class="modal-field">
<text class="modal-label">卡种名称</text>
<input
class="modal-input"
v-model="form.name"
placeholder="如10次课套餐"
placeholder-style="color:#bbb"
/>
</view>
<view class="modal-field">
<text class="modal-label">类型</text>
<picker
mode="selector"
:range="typeOptions"
range-key="label"
:value="form.typeIdx"
@change="onTypeChange"
>
<view class="picker-display">
<text class="picker-text">{{ typeOptions[form.typeIdx].label }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<view class="modal-field">
<text class="modal-label">现价</text>
<input
class="modal-input"
type="digit"
v-model="form.priceStr"
placeholder="如980"
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="number"
v-model="form.totalTimesStr"
placeholder="次卡必填,月卡留空"
placeholder-style="color:#bbb"
/>
</view>
<view class="modal-field">
<text class="modal-label">有效天数</text>
<input
class="modal-input"
type="number"
v-model="form.durationDaysStr"
placeholder="如90"
placeholder-style="color:#bbb"
/>
</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="200"
auto-height
/>
</view>
<!-- Cover image upload -->
<view class="modal-field modal-field--cover">
<text class="modal-label">封面图</text>
<view class="cover-upload-area">
<view v-if="form.coverUrl" class="cover-preview-wrap">
<image class="cover-preview-img" :src="form.coverUrl" mode="aspectFill" />
<view class="cover-remove-btn" @tap="clearCover">
<text class="cover-remove-icon"></text>
</view>
</view>
<view
v-else
class="cover-upload-btn"
:class="{ 'cover-upload-btn--loading': uploadingCover }"
@tap="uploadCover"
>
<text v-if="uploadingCover" class="cover-upload-hint">上传中...</text>
<template v-else>
<text class="cover-upload-plus"></text>
<text class="cover-upload-hint">上传封面</text>
</template>
</view>
<text class="cover-upload-tip">可选建议 3:2 比例</text>
</view>
</view>
</view>
</view>
</scroll-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 } from 'vue'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from '../../stores/admin'
import { formatPrice } from '../../utils/format'
import { uploadStudioAsset } from '../../utils/studio-upload'
import { CardTypeCategory } from '@mp-pilates/shared'
import type { CardType } from '@mp-pilates/shared'
const adminStore = useAdminStore()
const navBarHeight = ref('64px')
onMounted(() => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
})
const cardTypes = ref<CardType[]>([])
const loading = ref(false)
const showModal = ref(false)
const submitting = ref(false)
const uploadingCover = ref(false)
const editTarget = ref<CardType | null>(null)
const typeOptions = [
@@ -217,7 +265,7 @@ const typeOptions = [
{ label: '体验卡', value: CardTypeCategory.TRIAL },
]
const form = ref({
const defaultForm = () => ({
name: '',
typeIdx: 0,
priceStr: '',
@@ -226,8 +274,13 @@ const form = ref({
durationDaysStr: '90',
sortOrderStr: '0',
description: '',
coverUrl: '',
})
const form = ref(defaultForm())
// ─── Data loading ────────────────────────────────────
async function fetchCardTypes() {
loading.value = true
try {
@@ -239,18 +292,11 @@ async function fetchCardTypes() {
}
}
// ─── Modal open / close ──────────────────────────────
function openAdd() {
editTarget.value = null
form.value = {
name: '',
typeIdx: 0,
priceStr: '',
originalPriceStr: '',
totalTimesStr: '',
durationDaysStr: '90',
sortOrderStr: '0',
description: '',
}
form.value = defaultForm()
showModal.value = true
}
@@ -259,12 +305,13 @@ function openEdit(ct: CardType) {
form.value = {
name: ct.name,
typeIdx: typeOptions.findIndex((t) => t.value === ct.type),
priceStr: String(ct.price),
originalPriceStr: ct.originalPrice ? String(ct.originalPrice) : '',
priceStr: String(Number(ct.price) / 100),
originalPriceStr: ct.originalPrice ? String(Number(ct.originalPrice) / 100) : '',
totalTimesStr: ct.totalTimes ? String(ct.totalTimes) : '',
durationDaysStr: String(ct.durationDays),
sortOrderStr: String(ct.sortOrder),
description: ct.description ?? '',
coverUrl: ct.coverUrl ?? '',
}
showModal.value = true
}
@@ -274,8 +321,16 @@ function closeModal() {
editTarget.value = null
}
function onTypeChange(e: { detail: { value: number } }) {
form.value.typeIdx = Number(e.detail.value)
}
// ─── Form submit ─────────────────────────────────────
async function submitForm() {
if (submitting.value) return
// Validation
if (!form.value.name.trim()) {
uni.showToast({ title: '请填写卡种名称', icon: 'none' })
return
@@ -291,23 +346,42 @@ async function submitForm() {
return
}
const selectedType = typeOptions[form.value.typeIdx].value
const totalTimes = form.value.totalTimesStr ? parseInt(form.value.totalTimesStr, 10) : null
// Times-based card must have totalTimes
if (
(selectedType === CardTypeCategory.TIMES || selectedType === CardTypeCategory.TRIAL) &&
(!totalTimes || totalTimes < 1)
) {
uni.showToast({ title: '次卡/体验卡请填写次数', icon: 'none' })
return
}
// Convert yuan → cents for storage
const priceCents = Math.round(price * 100)
const payload: Record<string, unknown> = {
name: form.value.name.trim(),
type: typeOptions[form.value.typeIdx].value,
price,
type: selectedType,
price: priceCents,
durationDays,
sortOrder: parseInt(form.value.sortOrderStr, 10) || 0,
}
if (form.value.originalPriceStr) {
payload.originalPrice = parseFloat(form.value.originalPriceStr)
const originalPrice = parseFloat(form.value.originalPriceStr)
payload.originalPrice = Math.round(originalPrice * 100)
}
if (form.value.totalTimesStr) {
payload.totalTimes = parseInt(form.value.totalTimesStr, 10)
if (totalTimes) {
payload.totalTimes = totalTimes
}
if (form.value.description.trim()) {
payload.description = form.value.description.trim()
}
if (form.value.coverUrl) {
payload.coverUrl = form.value.coverUrl
}
submitting.value = true
try {
@@ -319,33 +393,70 @@ async function submitForm() {
uni.showToast({ title: '保存成功', icon: 'success' })
closeModal()
await fetchCardTypes()
} catch (e: any) {
uni.showToast({ title: e?.message ?? '保存失败', icon: 'none' })
} catch (e: unknown) {
const message = e instanceof Error ? e.message : '保存失败'
uni.showToast({ title: message, icon: 'none' })
} finally {
submitting.value = false
}
}
async function toggleActive(ct: CardType) {
try {
await adminStore.updateCardType(ct.id, { isActive: !ct.isActive })
await fetchCardTypes()
} catch {
uni.showToast({ title: '操作失败', icon: 'none' })
}
// ─── Toggle active (上架 / 下架) ─────────────────────
function confirmToggle(ct: CardType) {
const action = ct.isActive ? '下架' : '上架'
const content = ct.isActive
? `下架后用户将无法购买「${ct.name}」,已持有的会员卡不受影响。`
: `上架后「${ct.name}」将重新对用户可见并可购买。`
uni.showModal({
title: `确认${action}`,
content,
confirmText: action,
confirmColor: ct.isActive ? '#e67e22' : '#27ae60',
cancelText: '取消',
success: async (res) => {
if (res.confirm) {
uni.showLoading({ title: `${action}中...` })
try {
await adminStore.updateCardType(ct.id, { isActive: !ct.isActive } as any)
uni.hideLoading()
uni.showToast({ title: `${action}`, icon: 'success' })
await fetchCardTypes()
} catch {
uni.hideLoading()
uni.showToast({ title: `${action}失败`, icon: 'none' })
}
}
},
})
}
// ─── Delete ──────────────────────────────────────────
function confirmDelete(ct: CardType) {
uni.showModal({
title: '确认删除',
content: `删除卡种「${ct.name}」?此操作不可恢复`,
content: `删除卡种「${ct.name}」?\n若有用户已购买此卡种将自动下架而非删除`,
confirmText: '删除',
confirmColor: '#c0392b',
cancelText: '取消',
success: async (res) => {
if (res.confirm) {
uni.showLoading({ title: '删除中...' })
try {
await adminStore.deleteCardType(ct.id)
uni.showToast({ title: '已删除', icon: 'success' })
const result = await adminStore.deleteCardType(ct.id)
uni.hideLoading()
// result may contain { deleted, deactivated } from server
const resultData = result as unknown as { deleted?: boolean; deactivated?: boolean }
if (resultData?.deactivated) {
uni.showToast({ title: '存在关联数据,已自动下架', icon: 'none', duration: 2500 })
} else {
uni.showToast({ title: '已删除', icon: 'success' })
}
await fetchCardTypes()
} catch {
uni.hideLoading()
uni.showToast({ title: '删除失败', icon: 'none' })
}
}
@@ -353,6 +464,87 @@ function confirmDelete(ct: CardType) {
})
}
// ─── Cover image upload ─────────────────────────────
async function uploadCover() {
if (uploadingCover.value) return
try {
const file = await chooseSingleImage()
if (!file) return
uploadingCover.value = true
const url = await uploadStudioAsset({
adminStore,
filePath: file.path,
fileName: file.name,
assetType: 'card-cover',
})
form.value.coverUrl = url
uni.showToast({ title: '上传成功', icon: 'success' })
} catch (error: unknown) {
const message = error instanceof Error ? error.message : '上传失败'
uni.showToast({ title: message, icon: 'none' })
} finally {
uploadingCover.value = false
}
}
function clearCover() {
form.value.coverUrl = ''
}
interface PickedImage {
readonly path: string
readonly name: string
}
function extractFileName(filePath: string): string {
return filePath.split('/').pop() || `image_${Date.now()}.jpg`
}
function chooseSingleImage(): Promise<PickedImage | null> {
return new Promise((resolve, reject) => {
uni.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: (result) => {
const tempFilePaths = Array.isArray(result.tempFilePaths)
? result.tempFilePaths
: typeof result.tempFilePaths === 'string'
? [result.tempFilePaths]
: []
const path = tempFilePaths[0]
if (!path) {
resolve(null)
return
}
const tempFiles = Array.isArray(result.tempFiles)
? result.tempFiles
: result.tempFiles
? [result.tempFiles]
: []
const file = tempFiles[0] as { path?: string; tempFilePath?: string; name?: string } | undefined
resolve({
path,
name: file?.name || extractFileName(file?.path || file?.tempFilePath || path),
})
},
fail: (error) => {
if ((error.errMsg || '').includes('cancel')) {
resolve(null)
return
}
reject(new Error(error.errMsg || '选择图片失败'))
},
})
})
}
// ─── Helpers ─────────────────────────────────────────
function typeLabel(ct: CardType): string {
const map: Record<CardTypeCategory, string> = {
[CardTypeCategory.TIMES]: '次卡',
@@ -368,6 +560,8 @@ function headerClass(ct: CardType): string {
return 'header--times'
}
// ─── Lifecycle ───────────────────────────────────────
onMounted(fetchCardTypes)
</script>
@@ -394,7 +588,7 @@ onMounted(fetchCardTypes)
padding: 12rpx 28rpx;
}
.add-btn-text { font-size: 26rpx; font-weight: 600; color: #c9a87c; }
.add-btn-text { font-size: 26rpx; font-weight: 600; color: $primary-dark; }
/* ── Skeleton ────────────────────────────── */
.skeleton-list { padding: 0 24rpx; }
@@ -403,16 +597,11 @@ onMounted(fetchCardTypes)
height: 260rpx;
border-radius: 16rpx;
margin-bottom: 20rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background: linear-gradient(90deg, #f0ece8 25%, #e8e4df 50%, #f0ece8 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
@keyframes shimmer {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}
/* ── Empty ───────────────────────────────── */
.empty-state {
display: flex;
@@ -435,7 +624,7 @@ onMounted(fetchCardTypes)
margin-bottom: 20rpx;
box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.08);
&--inactive { opacity: 0.6; }
&--inactive { opacity: 0.55; }
}
.ct-header {
@@ -445,16 +634,16 @@ onMounted(fetchCardTypes)
justify-content: space-between;
}
.header--times { background: linear-gradient(90deg, #1a1a2e, #2d2d5e); }
.header--duration { background: linear-gradient(90deg, #6c3483, #9b59b6); }
.header--trial { background: linear-gradient(90deg, #7d6608, #c9a87c); }
.header--times { background: linear-gradient(90deg, #E8D5C4, #D4BFA8); }
.header--duration { background: linear-gradient(90deg, #D8C8DC, #C4AECB); }
.header--trial { background: linear-gradient(90deg, #C8D8D2, #A9C4BC); }
.ct-type-label { font-size: 22rpx; font-weight: 600; color: #ffffff; letter-spacing: 2rpx; }
.ct-type-label { font-size: 22rpx; font-weight: 600; color: $brand-color; letter-spacing: 2rpx; }
.ct-status-tag { border-radius: 20rpx; padding: 4rpx 16rpx; }
.tag--on { background: rgba(255,255,255,0.2); }
.tag--off { background: rgba(0,0,0,0.2); }
.ct-status-text { font-size: 20rpx; color: #ffffff; }
.tag--on { background: rgba(74, 64, 53, 0.1); }
.tag--off { background: rgba(74, 64, 53, 0.08); }
.ct-status-text { font-size: 20rpx; color: $brand-color; }
.ct-body { padding: 24rpx; }
@@ -473,7 +662,7 @@ onMounted(fetchCardTypes)
margin-bottom: 12rpx;
}
.ct-price { font-size: 40rpx; font-weight: 800; color: #c9a87c; }
.ct-price { font-size: 40rpx; font-weight: 800; color: $primary-dark; }
.ct-original {
font-size: 24rpx;
@@ -510,6 +699,8 @@ onMounted(fetchCardTypes)
border-right: 1rpx solid #f5f5f5;
&:last-child { border-right: none; }
&:active { background: #f9f9f9; }
}
.ct-action-text { font-size: 26rpx; font-weight: 600; }
@@ -526,23 +717,58 @@ onMounted(fetchCardTypes)
background: rgba(0,0,0,0.5);
display: flex;
align-items: flex-end;
z-index: 100;
z-index: 1000;
}
.modal {
.modal-container {
width: 100%;
max-height: 85vh;
background: #ffffff;
border-radius: 24rpx 24rpx 0 0;
padding: 40rpx 32rpx 60rpx;
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: #ffffff;
z-index: 10;
}
.modal-title {
font-size: 32rpx;
font-weight: 700;
color: #1a1a2e;
display: block;
margin-bottom: 24rpx;
}
.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 {
@@ -575,7 +801,8 @@ onMounted(fetchCardTypes)
.modal-actions {
display: flex;
gap: 16rpx;
margin-top: 32rpx;
padding: 24rpx 32rpx calc(24rpx + env(safe-area-inset-bottom));
background: #ffffff;
}
.modal-cancel {
@@ -586,6 +813,8 @@ onMounted(fetchCardTypes)
display: flex;
align-items: center;
justify-content: center;
&:active { background: #e8e8e8; }
}
.modal-cancel-text { font-size: 28rpx; color: #555; }
@@ -599,8 +828,87 @@ onMounted(fetchCardTypes)
align-items: center;
justify-content: center;
&--loading { opacity: 0.6; }
&:active { opacity: 0.85; }
&--loading { opacity: 0.6; pointer-events: none; }
}
.modal-confirm-text { font-size: 28rpx; font-weight: 700; color: #c9a87c; }
.modal-confirm-text { font-size: 28rpx; font-weight: 700; color: $primary-dark; }
/* ── Cover upload ───────────────────────── */
.modal-field--cover {
flex-direction: column;
align-items: flex-start;
gap: 16rpx;
border-bottom: none;
}
.cover-upload-area {
width: 100%;
display: flex;
flex-direction: column;
gap: 12rpx;
}
.cover-preview-wrap {
position: relative;
width: 300rpx;
height: 200rpx;
border-radius: 12rpx;
overflow: hidden;
}
.cover-preview-img {
width: 100%;
height: 100%;
}
.cover-remove-btn {
position: absolute;
top: 8rpx;
right: 8rpx;
width: 44rpx;
height: 44rpx;
background: rgba(0, 0, 0, 0.5);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.cover-remove-icon {
font-size: 20rpx;
color: #ffffff;
}
.cover-upload-btn {
width: 300rpx;
height: 200rpx;
border: 2rpx dashed #ddd;
border-radius: 12rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8rpx;
background: #fafafa;
&:active { background: #f0f0f0; }
&--loading { opacity: 0.6; pointer-events: none; }
}
.cover-upload-plus {
font-size: 48rpx;
color: #bbb;
line-height: 1;
}
.cover-upload-hint {
font-size: 22rpx;
color: #999;
}
.cover-upload-tip {
font-size: 20rpx;
color: #bbb;
}
</style>

View File

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

View File

@@ -1,61 +1,200 @@
<template>
<view class="page">
<!-- Stats row -->
<view class="stats-row">
<view v-if="statsLoading" class="stats-shimmer-wrap">
<view v-for="i in 3" :key="i" class="stats-shimmer" />
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="管理中心" show-back />
<!-- Stats summary card -->
<view class="stats-card-wrap">
<view class="stats-card">
<view v-if="statsLoading" class="stats-loading">
<view v-for="i in 3" :key="i" class="stat-skeleton" />
</view>
<template v-else>
<view class="stat-block">
<text class="stat-num">{{ stats.todayBookings }}</text>
<text class="stat-sub">今日预约</text>
</view>
<view class="stat-sep" />
<view class="stat-block">
<text class="stat-num">{{ stats.totalOrders }}</text>
<text class="stat-sub">总订单</text>
</view>
<view class="stat-sep" />
<view class="stat-block">
<text class="stat-num">{{ stats.totalBookings }}</text>
<text class="stat-sub">总预约</text>
</view>
</template>
</view>
<template v-else>
<view class="stat-item">
<text class="stat-value">{{ stats.todayBookings }}</text>
<text class="stat-label">今日预约</text>
</view>
<view class="stat-divider" />
<view class="stat-item">
<text class="stat-value">{{ stats.totalOrders }}</text>
<text class="stat-label">总订单</text>
</view>
<view class="stat-divider" />
<view class="stat-item">
<text class="stat-value">{{ stats.totalBookings }}</text>
<text class="stat-label">总预约</text>
</view>
</template>
</view>
<!-- Nav grid -->
<view class="nav-grid">
<view
v-for="item in navItems"
:key="item.path"
class="nav-item"
@tap="navigate(item.path)"
>
<text class="nav-icon">{{ item.icon }}</text>
<text class="nav-label">{{ item.label }}</text>
<!-- Section header: 课程管理 -->
<view class="section-header">
<text class="section-title">课程管理</text>
</view>
<!-- List: schedule -->
<view class="list">
<view class="list-item" @tap="navigate('/pages/admin/bookings')">
<view class="item-left">
<view class="item-icon-wrap icon--bookings">
<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 class="list-item" @tap="navigate('/pages/admin/schedule')">
<view class="item-left">
<view class="item-icon-wrap icon--schedule">
<text class="item-icon-text"></text>
</view>
<view class="item-text-group">
<text class="item-title">排课管理</text>
<text class="item-desc">管理每周课程时段</text>
</view>
</view>
<view class="item-arrow">
<text class="arrow-text"></text>
</view>
</view>
</view>
<!-- Section header: 会员与订单 -->
<view class="section-header">
<text class="section-title">会员与订单</text>
</view>
<!-- List: members & orders -->
<view class="list">
<view class="list-item" @tap="navigate('/pages/admin/members')">
<view class="item-left">
<view class="item-icon-wrap icon--members">
<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 class="list-item" @tap="navigate('/pages/admin/orders')">
<view class="item-left">
<view class="item-icon-wrap icon--orders">
<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 class="list-item" @tap="navigate('/pages/admin/card-types')">
<view class="item-left">
<view class="item-icon-wrap icon--card">
<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 class="list-item" @tap="navigate('/pages/admin/flash-sales')">
<view class="item-left">
<view class="item-icon-wrap icon--flash-sale">
<text class="item-icon-text"></text>
</view>
<view class="item-text-group">
<text class="item-title">秒杀管理</text>
<text class="item-desc">创建和管理限时秒杀活动</text>
</view>
</view>
<view class="item-arrow">
<text class="arrow-text"></text>
</view>
</view>
</view>
<!-- Section header: 系统 -->
<view class="section-header">
<text class="section-title">系统</text>
</view>
<!-- List: settings -->
<view class="list">
<view class="list-item" @tap="navigate('/pages/admin/studio')">
<view class="item-left">
<view class="item-icon-wrap icon--studio">
<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 class="list-item" @tap="handleIncreaseSubscriptionCount">
<view class="item-left">
<view class="item-icon-wrap icon--subscribe">
<text class="item-icon-text"></text>
</view>
<view class="item-text-group">
<text class="item-title">增加订阅次数</text>
<text class="item-desc">当前剩余 {{ user?.adminBookingSubscriptionCount ?? 0 }} </text>
</view>
</view>
<view class="item-arrow">
<text class="arrow-text">{{ adminSubscribeLoading ? '...' : '' }}</text>
</view>
</view>
</view>
<view style="height: 40rpx" />
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from '../../stores/admin'
import { useUserStore } from '../../stores/user'
import type { AdminStats } from '../../stores/admin'
import { requestAdminBookingSubscriptionCount } from '../../utils/wechat-subscription'
import { getErrorMessage } from '../../utils/auth'
const navBarHeight = ref('64px')
const adminStore = useAdminStore()
const userStore = useUserStore()
const { user } = storeToRefs(userStore)
const statsLoading = ref(false)
const stats = ref<AdminStats>({ todayBookings: 0, totalOrders: 0, totalBookings: 0 })
const navItems = [
{ icon: '📅', label: '排课设置', path: '/pages/admin/week-template' },
{ icon: '🔧', label: '临时调整', path: '/pages/admin/slot-adjust' },
{ icon: '👥', label: '会员管理', path: '/pages/admin/members' },
{ icon: '📋', label: '订单管理', path: '/pages/admin/orders' },
{ icon: '💳', label: '卡种管理', path: '/pages/admin/card-types' },
{ icon: '🏢', label: '工作室设置', path: '/pages/admin/studio' },
]
const adminSubscribeLoading = ref(false)
function navigate(path: string) {
uni.navigateTo({ url: path })
@@ -72,105 +211,201 @@ async function loadStats() {
}
}
onMounted(loadStats)
async function handleIncreaseSubscriptionCount() {
if (adminSubscribeLoading.value) {
return
}
adminSubscribeLoading.value = true
try {
const profile = await requestAdminBookingSubscriptionCount()
if (!profile) {
uni.showToast({ title: '已取消本次授权', icon: 'none' })
return
}
userStore.setProfile(profile)
uni.showToast({ title: `订阅次数 +1剩余 ${profile.adminBookingSubscriptionCount}`, icon: 'none' })
} catch (err: unknown) {
uni.showToast({ title: getErrorMessage(err, '订阅授权失败'), icon: 'none' })
} finally {
adminSubscribeLoading.value = false
}
}
onMounted(() => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
loadStats()
userStore.fetchProfile()
})
</script>
<style lang="scss" scoped>
/* ── Page ───────────────────────────────────── */
.page {
min-height: 100vh;
background: #1a1a2e;
padding-bottom: 60rpx;
padding-bottom: 40rpx;
}
/* ── Stats row ───────────────────────────── */
.stats-row {
display: flex;
align-items: center;
justify-content: space-around;
background: rgba(255, 255, 255, 0.06);
margin: 24rpx 24rpx 32rpx;
/* ── Stats card ─────────────────────────────── */
.stats-card-wrap {
padding: 24rpx 24rpx 8rpx;
}
.stats-card {
background: #FFFFFF;
border-radius: 20rpx;
padding: 32rpx 16rpx;
}
.stats-shimmer-wrap {
padding: 32rpx 24rpx;
display: flex;
width: 100%;
justify-content: space-around;
align-items: center;
box-shadow: 0 4rpx 20rpx rgba(180, 160, 130, 0.10);
border: 1rpx solid rgba(180, 160, 130, 0.12);
}
.stats-shimmer {
width: 120rpx;
height: 60rpx;
.stats-loading {
width: 100%;
display: flex;
align-items: center;
justify-content: space-around;
}
.stat-skeleton {
width: 100rpx;
height: 64rpx;
border-radius: 12rpx;
background: linear-gradient(90deg, rgba(255,255,255,0.08) 25%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.08) 75%);
background: linear-gradient(90deg, $primary-border 25%, $primary-light 50%, $primary-border 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
animation: shimmer 1.6s ease infinite;
}
@keyframes shimmer {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}
.stat-item {
.stat-block {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
flex: 1;
}
.stat-value {
.stat-num {
font-size: 44rpx;
font-weight: 800;
color: #c9a87c;
font-weight: 700;
color: #4A4035;
line-height: 1;
font-family: 'DIN Alternate', 'Helvetica Neue', Arial, sans-serif;
}
.stat-label {
.stat-sub {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.5);
color: #A09080;
letter-spacing: 0.5rpx;
}
.stat-divider {
.stat-sep {
width: 1rpx;
height: 60rpx;
background: rgba(255, 255, 255, 0.12);
height: 56rpx;
background: rgba(180, 160, 130, 0.2);
}
/* ── Nav grid ────────────────────────────── */
.nav-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20rpx;
padding: 0 24rpx;
/* ── Section header ─────────────────────────── */
.section-header {
padding: 32rpx 24rpx 12rpx;
}
.nav-item {
background: rgba(255, 255, 255, 0.06);
.section-title {
font-size: 22rpx;
font-weight: 600;
color: #A09080;
letter-spacing: 2rpx;
text-transform: uppercase;
}
/* ── List ───────────────────────────────────── */
.list {
background: #FFFFFF;
margin: 0 24rpx;
border-radius: 20rpx;
padding: 40rpx 0;
overflow: hidden;
box-shadow: 0 4rpx 20rpx rgba(180, 160, 130, 0.08);
border: 1rpx solid rgba(180, 160, 130, 0.1);
}
.list-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
border: 1rpx solid rgba(201, 168, 124, 0.15);
justify-content: space-between;
padding: 28rpx 24rpx;
border-bottom: 1rpx solid rgba(180, 160, 130, 0.1);
transition: background 0.15s ease;
&:last-child {
border-bottom: none;
}
&:active {
opacity: 0.7;
background: rgba(180, 160, 130, 0.05);
}
}
.nav-icon {
font-size: 56rpx;
.item-left {
display: flex;
align-items: center;
gap: 20rpx;
}
.nav-label {
font-size: 28rpx;
.item-icon-wrap {
width: 72rpx;
height: 72rpx;
border-radius: 18rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.item-icon-text {
font-size: 32rpx;
color: #FFFFFF;
line-height: 1;
}
/* Icon variants — warm muted tones */
.icon--bookings { background: linear-gradient(135deg, #C4A87E, #B49868); }
.icon--schedule { background: linear-gradient(135deg, #8B9E7E, #7A8E6E); }
.icon--template { background: linear-gradient(135deg, #A090C0, #9080B0); }
.icon--members { background: linear-gradient(135deg, $primary-color, $primary-dark); }
.icon--orders { background: linear-gradient(135deg, #7E9EC4, #6E8EB4); }
.icon--card { background: linear-gradient(135deg, #C48E7E, #B47E6E); }
.icon--flash-sale { background: linear-gradient(135deg, #D4A59A, #C08B7E); }
.icon--studio { background: linear-gradient(135deg, #9E9E7E, #8E8E6E); }
.icon--subscribe { background: linear-gradient(135deg, #5D8C8A, #476D72); }
.item-text-group {
display: flex;
flex-direction: column;
gap: 4rpx;
}
.item-title {
font-size: 30rpx;
font-weight: 600;
color: #ffffff;
letter-spacing: 1rpx;
color: #4A4035;
letter-spacing: 0.5rpx;
}
.item-desc {
font-size: 24rpx;
color: #A09080;
}
.item-arrow {
flex-shrink: 0;
padding-left: 16rpx;
}
.arrow-text {
font-size: 40rpx;
color: rgba(180, 160, 130, 0.5);
font-weight: 300;
line-height: 1;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,94 +1,162 @@
<template>
<view class="page">
<!-- Status filter tabs -->
<scroll-view scroll-x class="filter-scroll" :show-scrollbar="false">
<view class="filter-row">
<view
v-for="f in filters"
:key="f.value"
class="filter-chip"
:class="{ 'filter-chip--active': activeFilter === f.value }"
@tap="selectFilter(f.value)"
>
<text class="filter-chip-text">{{ f.label }}</text>
</view>
</view>
</scroll-view>
<view class="page" :style="{ '--status-bar': statusBarHeight + 'px' }">
<CustomNavBar title="订单管理" show-back />
<!-- Pull-to-refresh wrapper -->
<!-- Summary stats bar -->
<view class="stats-bar">
<view class="stat-item">
<text class="stat-num">{{ totalCount || '--' }}</text>
<text class="stat-label">全部订单</text>
</view>
<view class="stat-divider" />
<view class="stat-item">
<text class="stat-num paid">{{ paidCount || '--' }}</text>
<text class="stat-label">已支付</text>
</view>
<view class="stat-divider" />
<view class="stat-item">
<text class="stat-num pending">{{ pendingCount || '--' }}</text>
<text class="stat-label">待支付</text>
</view>
</view>
<!-- Status filter tabs -->
<view class="filter-wrap">
<scroll-view scroll-x class="filter-scroll" :show-scrollbar="false">
<view class="filter-row">
<view
v-for="f in filters"
:key="f.value"
class="filter-pill"
:class="{ active: activeFilter === f.value }"
@tap="selectFilter(f.value)"
>
<text class="filter-pill-text">{{ f.label }}</text>
<view v-if="f.count != null" class="filter-pill-dot" />
</view>
</view>
</scroll-view>
</view>
<!-- Pull-to-refresh -->
<scroll-view
scroll-y
class="list-scroll"
:refresher-enabled="true"
:refresher-triggered="refreshing"
:lower-threshold="120"
@refresherrefresh="onRefresh"
@scrolltolower="loadMore"
>
<!-- Loading skeleton -->
<view v-if="loading && !orders.length" class="skeleton-list">
<view v-for="i in 5" :key="i" class="skeleton-item" />
<view v-if="loading && !orders.length" class="order-list">
<view v-for="i in 5" :key="i" class="skeleton-card" />
</view>
<!-- Empty -->
<view v-else-if="!loading && !orders.length" class="empty-state">
<text class="empty-icon">📋</text>
<text class="empty-text">暂无订单</text>
<view class="empty-illustration">
<text class="empty-icon">📭</text>
</view>
<text class="empty-title">暂无订单</text>
<text class="empty-sub">当前筛选条件下没有找到订单</text>
</view>
<!-- Order list -->
<!-- Order cards -->
<view v-else class="order-list">
<view v-for="order in orders" :key="order.id" class="order-card">
<view class="order-header">
<text class="order-card-name">{{ order.cardType?.name ?? '-' }}</text>
<view class="order-status-badge" :class="statusBadgeClass(order.status)">
<text class="order-status-text">{{ statusLabel(order.status) }}</text>
<view
v-for="(order, idx) in orders"
:key="order.id"
class="order-card"
:class="{ 'order-card--paid': order.status === OrderStatus.PAID, 'order-card--pending': order.status === OrderStatus.PENDING }"
:style="{ animationDelay: `${idx * 40}ms` }"
>
<!-- Card accent bar -->
<view class="card-accent" :class="statusAccentClass(order.status)" />
<!-- Card header -->
<view class="card-header">
<view class="card-title-row">
<text class="card-plan">{{ order.cardType?.name ?? '未知套餐' }}</text>
<view class="badge" :class="statusBadgeClass(order.status)">
<text class="badge-text">{{ statusLabel(order.status) }}</text>
</view>
</view>
<text class="card-order-no">#{{ order.orderNo }}</text>
</view>
<view class="order-body">
<view class="order-row">
<text class="order-row-label">用户</text>
<text class="order-row-value">{{ order.user?.nickname ?? '-' }}</text>
<!-- Card divider -->
<view class="card-divider" />
<!-- Card body -->
<view class="card-body">
<view class="info-row">
<view class="info-left">
<text class="info-label">用户</text>
<text class="info-value">{{ order.user?.nickname ?? '未知用户' }}</text>
</view>
<view class="info-right">
<text class="info-label">手机</text>
<text class="info-value mono">{{ order.user?.phone ?? '未绑定' }}</text>
</view>
</view>
<view class="order-row">
<text class="order-row-label">手机</text>
<text class="order-row-value">{{ order.user?.phone ?? '未绑定' }}</text>
<view class="info-row">
<view class="info-left">
<text class="info-label">金额</text>
<text class="info-value price">¥{{ formatPrice(order.amount) }}</text>
</view>
<view class="info-right">
<text class="info-label">下单时间</text>
<text class="info-value">{{ formatDateTime(order.createdAt) }}</text>
</view>
</view>
<view class="order-row">
<text class="order-row-label">金额</text>
<text class="order-row-value order-price">¥{{ formatPrice(order.amount) }}</text>
</view>
<view class="order-row">
<text class="order-row-label">时间</text>
<text class="order-row-value">{{ formatDate(order.createdAt) }}</text>
<!-- Paid time if available -->
<view v-if="order.paidAt && order.status === OrderStatus.PAID" class="info-row">
<text class="info-label">支付时间</text>
<text class="info-value">{{ formatDateTime(order.paidAt) }}</text>
</view>
</view>
</view>
</view>
<!-- Load more -->
<view v-if="hasMore" class="load-more" @tap="loadMore">
<!-- Load more / no more -->
<view v-if="hasMore" class="load-more">
<text class="load-more-text">{{ loading ? '加载中...' : '加载更多' }}</text>
</view>
<view v-else-if="orders.length > 0" class="no-more">
<text class="no-more-text"> 已加载全部 {{ orders.length }} 条订单 </text>
</view>
<!-- Bottom spacer -->
<view style="height: 40rpx;" />
<view style="height: 60rpx" />
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { useAdminStore } from '../../stores/admin'
import { formatPrice, formatDate } from '../../utils/format'
import { formatPrice, formatDateTime } from '../../utils/format'
import { OrderStatus } from '@mp-pilates/shared'
import type { OrderWithDetails } from '@mp-pilates/shared'
const adminStore = useAdminStore()
// 动态计算顶部模块高度
const statusBarHeight = ref(0)
onMounted(() => {
const windowInfo = uni.getWindowInfo()
statusBarHeight.value = windowInfo.statusBarHeight ?? 20
})
const filters = [
{ label: '全部', value: '' },
{ label: '已支付', value: OrderStatus.PAID },
{ label: '待支付', value: OrderStatus.PENDING },
{ label: '已退款', value: OrderStatus.REFUNDED },
{ label: '全部', value: '', count: null },
{ label: '已支付', value: OrderStatus.PAID, count: null },
{ label: '待支付', value: OrderStatus.PENDING, count: null },
{ label: '已退款', value: OrderStatus.REFUNDED, count: null },
]
const activeFilter = ref('')
@@ -97,6 +165,24 @@ const loading = ref(false)
const refreshing = ref(false)
const page = ref(1)
const hasMore = ref(false)
const totalCount = ref<number | null>(null)
const paidCount = ref<number | null>(null)
const pendingCount = ref<number | null>(null)
// 每个 tab 单独缓存数据
const orderCache: Record<string, { items: OrderWithDetails[]; total: number; page: number; hasMore: boolean }> = {}
function getCacheKey(filter: string): string {
return filter || 'all'
}
function getCachedData(filter: string) {
return orderCache[getCacheKey(filter)]
}
function setCachedData(filter: string, data: { items: OrderWithDetails[]; total: number; page: number; hasMore: boolean }) {
orderCache[getCacheKey(filter)] = data
}
const LIMIT = 20
@@ -116,25 +202,111 @@ function statusBadgeClass(s: string) {
return 'badge--default'
}
function statusAccentClass(s: string) {
if (s === OrderStatus.PAID) return 'accent--paid'
if (s === OrderStatus.PENDING) return 'accent--pending'
if (s === OrderStatus.REFUNDED) return 'accent--refunded'
return ''
}
async function loadOrders(reset = false) {
if (loading.value) return
const filter = activeFilter.value
// 如果有缓存且是重置切换tab直接用缓存数据
if (reset) {
page.value = 1
orders.value = []
const cached = getCachedData(filter)
if (cached) {
orders.value = [...cached.items]
hasMore.value = cached.hasMore
page.value = cached.page
return
}
}
// 初始加载或下拉刷新,需要请求接口
if (loading.value) return
if (reset) page.value = 1
loading.value = true
try {
const result = await adminStore.fetchAdminOrders({
const params: { page: number; limit: number; status?: string } = {
page: page.value,
limit: LIMIT,
status: activeFilter.value || undefined,
})
if (reset) {
orders.value = [...result.items]
} else {
orders.value.push(...result.items)
}
hasMore.value = orders.value.length < result.total
if (filter) params.status = filter
const result = await adminStore.fetchAdminOrders(params)
const newItems = reset ? [...result.items] : [...orders.value, ...result.items]
const newHasMore = newItems.length < result.total
// 缓存数据
setCachedData(filter, {
items: newItems,
total: result.total,
page: page.value,
hasMore: newHasMore,
})
orders.value = newItems
hasMore.value = newHasMore
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
refreshing.value = false
}
}
// 初始加载所有分类的数据
async function loadAllFiltersData() {
loading.value = true
try {
// 并行请求所有分类(第一页数据)
const [allResult, paidResult, pendingResult, refundedResult] = await Promise.all([
adminStore.fetchAdminOrders({ page: 1, limit: LIMIT }),
adminStore.fetchAdminOrders({ page: 1, limit: LIMIT, status: OrderStatus.PAID }),
adminStore.fetchAdminOrders({ page: 1, limit: LIMIT, status: OrderStatus.PENDING }),
adminStore.fetchAdminOrders({ page: 1, limit: LIMIT, status: OrderStatus.REFUNDED }),
])
// 缓存全部
setCachedData('', {
items: [...allResult.items],
total: allResult.total,
page: 1,
hasMore: allResult.items.length < allResult.total,
})
totalCount.value = allResult.total
// 缓存已支付
setCachedData(OrderStatus.PAID, {
items: [...paidResult.items],
total: paidResult.total,
page: 1,
hasMore: paidResult.items.length < paidResult.total,
})
paidCount.value = paidResult.total
// 缓存待支付
setCachedData(OrderStatus.PENDING, {
items: [...pendingResult.items],
total: pendingResult.total,
page: 1,
hasMore: pendingResult.items.length < pendingResult.total,
})
pendingCount.value = pendingResult.total
// 缓存已退款
setCachedData(OrderStatus.REFUNDED, {
items: [...refundedResult.items],
total: refundedResult.total,
page: 1,
hasMore: refundedResult.items.length < refundedResult.total,
})
// 设置当前 tab 的数据
orders.value = [...allResult.items]
hasMore.value = allResult.items.length < allResult.total
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
@@ -145,12 +317,19 @@ async function loadOrders(reset = false) {
function selectFilter(value: string) {
activeFilter.value = value
loadOrders(true)
// 切换 tab 直接从缓存读取
const cached = getCachedData(value)
if (cached) {
orders.value = [...cached.items]
hasMore.value = cached.hasMore
page.value = cached.page
}
}
async function onRefresh() {
refreshing.value = true
await loadOrders(true)
// 下拉刷新重新请求所有分类的数据
await loadAllFiltersData()
}
function loadMore() {
@@ -159,136 +338,333 @@ function loadMore() {
loadOrders(false)
}
onMounted(() => loadOrders(true))
onMounted(() => {
loadAllFiltersData()
})
</script>
<style lang="scss" scoped>
/* ── Page shell ──────────────────────────────── */
.page {
height: 100vh;
display: flex;
flex-direction: column;
background: #f5f3f0;
background: #FAF8F5;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', sans-serif;
}
/* ── Filter scroll ───────────────────────── */
.filter-scroll {
flex-shrink: 0;
background: #ffffff;
border-bottom: 1rpx solid #eee;
/* ── Stats bar ──────────────────────────────── */
.stats-bar {
position: fixed;
top: calc(var(--status-bar) + 44px);
left: 0;
right: 0;
display: flex;
align-items: center;
height: 96rpx;
background: #FFFFFF;
padding: 0;
border-bottom: 1rpx solid rgba(180, 160, 130, 0.2);
box-shadow: 0 2rpx 12rpx rgba(180, 160, 130, 0.08);
z-index: 100;
}
.stat-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
}
.stat-num {
font-size: 42rpx;
font-weight: 700;
color: #4A4035;
letter-spacing: -1rpx;
line-height: 1;
font-family: 'DIN Alternate', 'Helvetica Neue', Arial, sans-serif;
}
.stat-num.paid { color: #7A9E7E; }
.stat-num.pending { color: $warning-color; }
.stat-label {
font-size: 22rpx;
color: #A09080;
letter-spacing: 1rpx;
}
.stat-divider {
width: 1rpx;
height: 48rpx;
background: rgba(180, 160, 130, 0.25);
}
/* ── Filter pills ───────────────────────────── */
.filter-wrap {
position: fixed;
top: calc(var(--status-bar) + 92px);
left: 0;
right: 0;
background: #FAF8F5;
border-bottom: 1rpx solid rgba(180, 160, 130, 0.15);
z-index: 99;
}
.filter-scroll { overflow: hidden; }
.filter-row {
display: flex;
align-items: center;
padding: 16rpx 24rpx;
padding: 20rpx 28rpx;
gap: 16rpx;
white-space: nowrap;
}
.filter-chip {
.filter-pill {
position: relative;
display: inline-flex;
align-items: center;
height: 60rpx;
padding: 0 28rpx;
border-radius: 30rpx;
background: #f0f0f0;
gap: 8rpx;
height: 64rpx;
padding: 0 32rpx;
border-radius: 32rpx;
background: rgba(180, 160, 130, 0.1);
border: 1.5rpx solid rgba(180, 160, 130, 0.2);
flex-shrink: 0;
transition: all 0.22s ease;
}
.filter-chip--active {
background: #1a1a2e;
.filter-pill.active {
background: #4A4035;
border-color: #4A4035;
}
.filter-chip-text { font-size: 26rpx; color: #888; }
.filter-chip--active .filter-chip-text { color: #c9a87c; font-weight: 600; }
.filter-pill-text {
font-size: 26rpx;
color: #7A6A5A;
font-weight: 500;
transition: color 0.22s ease;
}
/* ── List scroll ─────────────────────────── */
.filter-pill.active .filter-pill-text {
color: #E8D8C0;
font-weight: 600;
}
.filter-pill-dot {
width: 6rpx;
height: 6rpx;
border-radius: 50%;
background: $accent-color;
}
/* ── List ───────────────────────────────────── */
.list-scroll {
flex: 1;
position: fixed;
top: calc(var(--status-bar) + 144px);
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
}
/* ── Skeleton ────────────────────────────── */
.skeleton-list { padding: 24rpx; }
.order-list {
padding: 20rpx 24rpx 0;
display: flex;
flex-direction: column;
gap: 20rpx;
}
.skeleton-item {
height: 180rpx;
border-radius: 16rpx;
margin-bottom: 16rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
/* ── Skeleton ───────────────────────────────── */
.skeleton-card {
height: 220rpx;
border-radius: 20rpx;
background: linear-gradient(90deg, #F0EBE3 25%, #E8E0D5 50%, #F0EBE3 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
animation: shimmer 1.6s ease infinite;
}
@keyframes shimmer {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}
/* ── Empty ───────────────────────────────── */
/* ── Empty ──────────────────────────────────── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 120rpx 0;
gap: 20rpx;
padding: 120rpx 48rpx;
gap: 12rpx;
}
.empty-icon { font-size: 80rpx; }
.empty-text { font-size: 28rpx; color: #bbb; }
.empty-illustration {
width: 120rpx;
height: 120rpx;
border-radius: 60rpx;
background: rgba(180, 160, 130, 0.15);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12rpx;
}
/* ── Order list ──────────────────────────── */
.order-list { padding: 16rpx 24rpx 0; }
.empty-icon { font-size: 56rpx; }
.empty-title {
font-size: 32rpx;
font-weight: 600;
color: #4A4035;
letter-spacing: 0.5rpx;
}
.empty-sub {
font-size: 26rpx;
color: rgba(74, 64, 53, 0.4);
text-align: center;
}
/* ── Order card ─────────────────────────────── */
.order-card {
background: #ffffff;
border-radius: 16rpx;
overflow: hidden;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
}
.order-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 24rpx;
border-bottom: 1rpx solid #f5f5f5;
}
.order-card-name { font-size: 28rpx; font-weight: 600; color: #1a1a2e; }
.order-status-badge {
position: relative;
background: #FFFFFF;
border-radius: 20rpx;
padding: 6rpx 20rpx;
overflow: hidden;
box-shadow: 0 4rpx 20rpx rgba(180, 160, 130, 0.12);
animation: cardIn 0.4s ease both;
}
.badge--paid { background: rgba(39,174,96,0.1); }
.badge--paid .order-status-text { font-size: 22rpx; color: #27ae60; }
.badge--pending { background: rgba(230,126,34,0.1); }
.badge--pending .order-status-text { font-size: 22rpx; color: #e67e22; }
.badge--refunded { background: rgba(0,0,0,0.06); }
.badge--refunded .order-status-text { font-size: 22rpx; color: #999; }
.badge--default .order-status-text { font-size: 22rpx; color: #888; }
@keyframes cardIn {
from { opacity: 0; transform: translateY(12rpx); }
to { opacity: 1; transform: translateY(0); }
}
.order-body { padding: 16rpx 24rpx; }
.card-accent {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 6rpx;
}
.order-row {
.accent--paid { background: #8FCB9B; }
.accent--pending { background: #F2C94C; }
.accent--refunded { background: rgba(43, 43, 43, 0.2); }
.card-header {
padding: 24rpx 24rpx 20rpx 30rpx;
}
.card-title-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10rpx 0;
}
.order-row-label { font-size: 24rpx; color: #999; }
.order-row-value { font-size: 26rpx; color: #333; }
.order-price { font-size: 28rpx; font-weight: 700; color: #c9a87c; }
.card-plan {
font-size: 30rpx;
font-weight: 700;
color: #4A4035;
letter-spacing: 0.5rpx;
}
/* ── Load more ───────────────────────────── */
.card-order-no {
font-size: 22rpx;
color: rgba(74, 64, 53, 0.35);
margin-top: 6rpx;
display: block;
font-family: 'SF Mono', 'Menlo', monospace;
}
.card-divider {
height: 1rpx;
background: rgba(180, 160, 130, 0.15);
margin: 0 24rpx;
}
.card-body {
padding: 20rpx 24rpx 20rpx 30rpx;
display: flex;
flex-direction: column;
gap: 12rpx;
}
.info-row {
display: flex;
align-items: center;
}
.info-left,
.info-right {
flex: 1;
display: flex;
align-items: center;
gap: 12rpx;
}
.info-right {
justify-content: flex-end;
}
.info-label {
font-size: 24rpx;
color: rgba(74, 64, 53, 0.4);
flex-shrink: 0;
min-width: 80rpx;
}
.info-value {
font-size: 26rpx;
color: #4A4035;
font-weight: 500;
}
.info-value.mono {
font-family: 'SF Mono', 'Menlo', monospace;
font-size: 24rpx;
}
.info-value.price {
font-size: 30rpx;
font-weight: 700;
color: $accent-color;
font-family: 'DIN Alternate', 'Helvetica Neue', Arial, sans-serif;
}
/* ── Status badges ─────────────────────────── */
.badge {
border-radius: 8rpx;
padding: 4rpx 14rpx;
}
.badge--paid { background: rgba(122, 158, 126, 0.15); }
.badge--paid .badge-text { font-size: 22rpx; color: #5A7E5E; font-weight: 600; }
.badge--pending { background: rgba(196, 149, 106, 0.2); }
.badge--pending .badge-text { font-size: 22rpx; color: #A07540; font-weight: 600; }
.badge--refunded { background: rgba(180, 160, 130, 0.15); }
.badge--refunded .badge-text { font-size: 22rpx; color: #8A7A6A; }
.badge--default .badge-text { font-size: 22rpx; color: #888; }
/* ── Load more ─────────────────────────────── */
.load-more {
text-align: center;
padding: 32rpx;
padding: 40rpx 0 20rpx;
}
.load-more-text { font-size: 26rpx; color: #c9a87c; }
.load-more-text {
font-size: 26rpx;
color: $accent-color;
font-weight: 500;
}
.no-more {
text-align: center;
padding: 32rpx 0 20rpx;
}
.no-more-text {
font-size: 24rpx;
color: rgba(74, 64, 53, 0.3);
letter-spacing: 0.5rpx;
}
</style>

View File

@@ -0,0 +1,757 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="排课管理" show-back />
<!-- Date selector -->
<view class="sticky-header">
<DateSelector v-model="selectedDate" @select="onDateSelect" />
</view>
<!-- Loading skeleton -->
<view v-if="loading" class="skeleton-list">
<view v-for="i in 4" :key="i" class="skeleton-item" />
</view>
<!-- Empty state -->
<view v-else-if="editableSlots.length === 0" class="empty-state">
<text class="empty-icon">📭</text>
<text class="empty-text">当日暂无排课</text>
<text class="empty-sub">当日暂无默认时段请点击下方按钮手动添加</text>
</view>
<!-- Slot list -->
<view v-else class="slot-list">
<view
v-for="slot in visibleSlots"
:key="slot.key"
class="slot-card"
:class="slotCardClass(slot)"
>
<!-- Status badge -->
<view class="slot-header">
<view class="slot-badge" :class="slotBadgeClass(slot)">
<text class="slot-badge-text">{{ slotBadgeText(slot) }}</text>
</view>
<view v-if="slot.bookedCount > 0" class="booked-info">
<text class="booked-text">{{ slot.bookedCount }} 人已预约</text>
</view>
</view>
<!-- Time display / edit -->
<view class="slot-body">
<view class="time-section">
<picker
mode="time"
:value="slot.startTime"
@change="(e: any) => updateSlotTime(slot, 'startTime', e.detail.value)"
>
<view class="time-display">
<text class="time-text">{{ slot.startTime }}</text>
</view>
</picker>
<text class="time-separator"></text>
<picker
mode="time"
:value="slot.endTime"
@change="(e: any) => updateSlotTime(slot, 'endTime', e.detail.value)"
>
<view class="time-display">
<text class="time-text">{{ slot.endTime }}</text>
</view>
</picker>
</view>
<view class="capacity-section">
<text class="capacity-label">容量</text>
<view class="capacity-control">
<view class="capacity-btn" @tap="adjustCapacity(slot, -1)">
<text class="capacity-btn-text"></text>
</view>
<text class="capacity-value">{{ slot.capacity }}</text>
<view class="capacity-btn" @tap="adjustCapacity(slot, 1)">
<text class="capacity-btn-text">+</text>
</view>
</view>
</view>
<view class="delete-section">
<view
class="delete-btn"
:class="{ 'delete-btn--warn': slot.bookedCount > 0 }"
@tap="removeSlot(slot)"
>
<text class="delete-btn-text"></text>
</view>
</view>
</view>
</view>
</view>
<!-- Add slot button -->
<view class="add-wrap" @tap="openAddModal">
<text class="add-text"> 添加时段</text>
</view>
<!-- Bottom action bar -->
<view class="action-bar">
<view
class="publish-btn"
:class="{ 'publish-btn--loading': publishing }"
@tap="handlePublish"
>
<text class="publish-btn-text">
{{ publishing ? '发布中...' : (hasPublished ? '更新当日排课' : '发布当日排课') }}
</text>
</view>
</view>
<!-- Add slot modal -->
<view v-if="showAddModal" class="modal-mask" @tap="onMaskTap">
<view class="modal" @tap.stop>
<text class="modal-title">添加时段</text>
<view class="modal-field">
<text class="modal-label">开始时间</text>
<picker
mode="time"
:value="addForm.startTime"
@change="onAddStartTimeChange"
>
<view class="picker-display">
<text class="picker-text">{{ addForm.startTime }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<view class="modal-field">
<text class="modal-label">结束时间</text>
<view class="picker-display picker-display--disabled">
<text class="picker-text picker-text--muted">{{ addForm.endTime }}</text>
</view>
</view>
<view class="modal-field modal-field--last">
<text class="modal-label">容量</text>
<input
class="modal-input"
type="number"
v-model="addForm.capacityStr"
placeholder="如1"
placeholder-style="color:#bbb"
/>
</view>
<view class="modal-actions">
<view class="modal-cancel" @tap="closeAddModal">
<text class="modal-cancel-text">取消</text>
</view>
<view class="modal-confirm" @tap="submitAdd">
<text class="modal-confirm-text">确认</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import type { ScheduleSlotPreview } from '@mp-pilates/shared'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from '../../stores/admin'
import { formatDate } from '../../utils/format'
import DateSelector from '../../components/DateSelector.vue'
interface EditableSlot {
readonly key: string
existingSlotId: string | null
startTime: string
endTime: string
capacity: number
bookedCount: number
isPublished: boolean
isNew: boolean
isRemoved: boolean
templateId: string | null
}
const adminStore = useAdminStore()
const navBarHeight = ref('64px')
const selectedDate = ref(formatDate(new Date()))
const loading = ref(false)
const publishing = ref(false)
const showAddModal = ref(false)
const editableSlots = ref<EditableSlot[]>([])
const addForm = ref({
startTime: '09:00',
endTime: '10:00',
capacityStr: '1',
})
// ── Computed ──────────────────────────────────────────────
const visibleSlots = computed(() =>
editableSlots.value.filter((s) => !s.isRemoved),
)
const hasPublished = computed(() =>
editableSlots.value.some((s) => s.isPublished),
)
// ── Data loading ──────────────────────────────────────────
function mapPreviewToEditable(previews: readonly ScheduleSlotPreview[]): EditableSlot[] {
return previews.map((p) => ({
key: p.id ?? `tpl-${p.templateId}-${p.startTime}`,
existingSlotId: p.id,
startTime: p.startTime,
endTime: p.endTime,
capacity: p.capacity,
bookedCount: p.bookedCount,
isPublished: p.isPublished,
isNew: false,
isRemoved: false,
templateId: p.templateId,
}))
}
async function loadPreview(date: string) {
loading.value = true
try {
const previews = await adminStore.fetchSchedulePreview(date)
editableSlots.value = mapPreviewToEditable(previews)
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
editableSlots.value = []
} finally {
loading.value = false
}
}
function onDateSelect(date: string) {
selectedDate.value = date
loadPreview(date)
}
// ── Slot editing ──────────────────────────────────────────
function updateSlotTime(slot: EditableSlot, field: 'startTime' | 'endTime', value: string) {
slot[field] = value
}
function adjustCapacity(slot: EditableSlot, delta: number) {
const minCapacity = Math.max(1, slot.bookedCount)
const newVal = slot.capacity + delta
if (newVal >= minCapacity) {
slot.capacity = newVal
}
}
function removeSlot(slot: EditableSlot) {
if (slot.bookedCount > 0) {
uni.showModal({
title: '该时段有预约',
content: `已有 ${slot.bookedCount} 人预约此时段,移除后该时段将被关闭(已有预约保留)。确认移除?`,
confirmText: '确认移除',
confirmColor: '#c0392b',
success: (res) => {
if (res.confirm) {
slot.isRemoved = true
}
},
})
} else {
slot.isRemoved = true
}
}
// ── Add slot ──────────────────────────────────────────────
/** 将 "HH:mm" 加一小时,最大 23:59 */
function addOneHour(time: string): string {
const [h, m] = time.split(':').map(Number)
const newH = Math.min(h + 1, 23)
// 如果原本就是 23:xx结束时间设为 23:59
if (h >= 23) return '23:59'
return String(newH).padStart(2, '0') + ':' + String(m).padStart(2, '0')
}
function onAddStartTimeChange(e: any) {
const start = e.detail.value as string
addForm.value.startTime = start
addForm.value.endTime = addOneHour(start)
}
function openAddModal() {
addForm.value = { startTime: '09:00', endTime: '10:00', capacityStr: '1' }
showAddModal.value = true
}
function closeAddModal() {
showAddModal.value = false
}
/** 点击遮罩关闭弹窗 — tap.stop 在 modal 上阻止了内部点击冒泡到此 */
function onMaskTap() {
closeAddModal()
}
function submitAdd() {
const capacity = parseInt(addForm.value.capacityStr, 10)
if (!addForm.value.startTime || !addForm.value.endTime) {
uni.showToast({ title: '请选择时间', icon: 'none' })
return
}
if (isNaN(capacity) || capacity < 1) {
uni.showToast({ title: '请填写有效容量', icon: 'none' })
return
}
editableSlots.value.push({
key: `new-${Date.now()}`,
existingSlotId: null,
startTime: addForm.value.startTime,
endTime: addForm.value.endTime,
capacity,
bookedCount: 0,
isPublished: false,
isNew: true,
isRemoved: false,
templateId: null,
})
closeAddModal()
}
// ── Publish ───────────────────────────────────────────────
async function handlePublish() {
if (publishing.value) return
const slotsToPublish = visibleSlots.value
if (slotsToPublish.length === 0) {
uni.showModal({
title: '提示',
content: '当前没有时段,确认清空当日排课?',
success: async (res) => {
if (res.confirm) {
await doPublish([])
}
},
})
return
}
// Validate times
for (const slot of slotsToPublish) {
if (slot.startTime >= slot.endTime) {
uni.showToast({ title: `时段 ${slot.startTime}-${slot.endTime} 时间无效`, icon: 'none' })
return
}
}
uni.showModal({
title: '确认发布',
content: `确认${hasPublished.value ? '更新' : '发布'} ${selectedDate.value} 的排课?共 ${slotsToPublish.length} 个时段`,
success: async (res) => {
if (res.confirm) {
await doPublish(slotsToPublish)
}
},
})
}
async function doPublish(slots: readonly EditableSlot[]) {
publishing.value = true
try {
await adminStore.publishDaySlots({
date: selectedDate.value,
slots: slots.map((s) => ({
existingSlotId: s.existingSlotId ?? undefined,
startTime: s.startTime,
endTime: s.endTime,
capacity: s.capacity,
})),
})
uni.showToast({ title: '发布成功', icon: 'success' })
// Reload to show fresh state
editableSlots.value = mapPreviewToEditable(adminStore.schedulePreview)
} catch (e: unknown) {
const message = e instanceof Error ? e.message : '发布失败'
uni.showToast({ title: message, icon: 'none' })
} finally {
publishing.value = false
}
}
// ── Style helpers ─────────────────────────────────────────
function slotCardClass(slot: EditableSlot): string {
if (slot.isNew) return 'slot-card--new'
if (slot.isPublished) return 'slot-card--published'
return 'slot-card--template'
}
function slotBadgeClass(slot: EditableSlot): string {
if (slot.isNew) return 'badge--new'
if (slot.isPublished) return 'badge--published'
return 'badge--template'
}
function slotBadgeText(slot: EditableSlot): string {
if (slot.isNew) return '新增'
if (slot.isPublished) return '已发布'
return '默认时段'
}
// ── Lifecycle ─────────────────────────────────────────────
onMounted(() => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
loadPreview(selectedDate.value)
})
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #f5f3f0;
padding-bottom: 180rpx;
}
/* ── Sticky header ───────────────────────── */
.sticky-header {
position: sticky;
top: 0;
z-index: 100;
background: #fff;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
}
/* ── Loading skeleton ────────────────────── */
.skeleton-list {
padding: 24rpx;
}
.skeleton-item {
height: 160rpx;
border-radius: 20rpx;
margin-bottom: 20rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
/* ── Empty state ─────────────────────────── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 100rpx 40rpx;
gap: 16rpx;
}
.empty-icon { font-size: 80rpx; }
.empty-text { font-size: 30rpx; color: #666; font-weight: 600; }
.empty-sub { font-size: 24rpx; color: #bbb; text-align: center; }
/* ── Slot list ───────────────────────────── */
.slot-list {
padding: 24rpx 24rpx 0;
display: flex;
flex-direction: column;
gap: 20rpx;
}
/* ── Slot card ───────────────────────────── */
.slot-card {
background: #ffffff;
border-radius: 20rpx;
padding: 24rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
border: 2rpx solid transparent;
&--published {
border-color: rgba(39, 174, 96, 0.3);
}
&--template {
border-style: dashed;
border-color: $primary-dark;
background: rgba(201, 168, 124, 0.04);
}
&--new {
border-style: dashed;
border-color: #3498db;
background: rgba(52, 152, 219, 0.04);
}
}
/* ── Slot header ─────────────────────────── */
.slot-header {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 16rpx;
}
.slot-badge {
border-radius: 16rpx;
padding: 4rpx 16rpx;
}
.badge--published { background: rgba(39, 174, 96, 0.1); }
.badge--published .slot-badge-text { font-size: 22rpx; color: #27ae60; font-weight: 600; }
.badge--template { background: rgba(201, 168, 124, 0.15); }
.badge--template .slot-badge-text { font-size: 22rpx; color: #b8860b; font-weight: 600; }
.badge--new { background: rgba(52, 152, 219, 0.1); }
.badge--new .slot-badge-text { font-size: 22rpx; color: #3498db; font-weight: 600; }
.booked-info { }
.booked-text { font-size: 22rpx; color: #e67e22; }
/* ── Slot body ───────────────────────────── */
.slot-body {
display: flex;
align-items: center;
gap: 16rpx;
}
.time-section {
display: flex;
align-items: center;
gap: 8rpx;
flex: 1;
}
.time-display {
background: #f7f4f0;
border-radius: 12rpx;
padding: 12rpx 20rpx;
}
.time-text {
font-size: 32rpx;
font-weight: 700;
color: #1a1a2e;
}
.time-separator {
font-size: 28rpx;
color: #999;
}
.capacity-section {
display: flex;
align-items: center;
gap: 8rpx;
}
.capacity-label {
font-size: 22rpx;
color: #888;
}
.capacity-control {
display: flex;
align-items: center;
gap: 4rpx;
}
.capacity-btn {
width: 48rpx;
height: 48rpx;
border-radius: 24rpx;
background: #f0ece8;
display: flex;
align-items: center;
justify-content: center;
&:active { opacity: 0.6; }
}
.capacity-btn-text {
font-size: 28rpx;
font-weight: 700;
color: #1a1a2e;
line-height: 1;
}
.capacity-value {
font-size: 28rpx;
font-weight: 700;
color: #1a1a2e;
min-width: 40rpx;
text-align: center;
}
.delete-section {
margin-left: 8rpx;
}
.delete-btn {
width: 48rpx;
height: 48rpx;
border-radius: 24rpx;
background: rgba(192, 57, 43, 0.08);
display: flex;
align-items: center;
justify-content: center;
&--warn {
background: rgba(192, 57, 43, 0.2);
}
&:active { opacity: 0.6; }
}
.delete-btn-text {
font-size: 24rpx;
color: #c0392b;
font-weight: 700;
}
/* ── Add button ──────────────────────────── */
.add-wrap {
margin: 24rpx;
padding: 24rpx;
border: 2rpx dashed $primary-dark;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
&:active { opacity: 0.6; }
}
.add-text {
font-size: 28rpx;
font-weight: 600;
color: $primary-dark;
}
/* ── Action bar ──────────────────────────── */
.action-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);
}
.publish-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; }
&:active { opacity: 0.85; }
}
.publish-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: 200;
}
.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-text--muted { color: #999; }
.picker-arrow { font-size: 26rpx; color: #bbb; }
.picker-display--disabled { opacity: 0.6; }
.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>

View File

@@ -1,5 +1,6 @@
<template>
<view class="page">
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="时段调整" show-back />
<!-- Tabs -->
<view class="tabs">
<view
@@ -127,7 +128,7 @@
</picker>
</view>
</view>
<text class="gen-hint">根据排课模板自动生成所选日期范围内的时段</text>
<text class="gen-hint">按默认时间表每天 8:00-22:00每小时一节自动生成所选日期范围内的时段</text>
<view class="action-wrap">
<view class="action-btn" :class="{ 'action-btn--loading': submitting }" @tap="submitGenerate">
<text class="action-btn-text">{{ submitting ? '生成中...' : '批量生成' }}</text>
@@ -138,13 +139,16 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from '../../stores/admin'
import { formatDate } from '../../utils/format'
import type { TimeSlot } from '@mp-pilates/shared'
const adminStore = useAdminStore()
const navBarHeight = ref('64px')
const tabs = ['新增时段', '关闭时段', '批量生成']
const activeTab = ref(0)
const submitting = ref(false)
@@ -242,6 +246,10 @@ async function submitGenerate() {
submitting.value = false
}
}
onMounted(() => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
})
</script>
<style lang="scss" scoped>
@@ -278,7 +286,7 @@ async function submitGenerate() {
}
.tab--active {
border-bottom: 4rpx solid #c9a87c;
border-bottom: 4rpx solid $primary-dark;
}
/* ── Panel ───────────────────────────────── */
@@ -335,11 +343,6 @@ async function submitGenerate() {
animation: shimmer 1.4s infinite;
}
@keyframes shimmer {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}
/* ── Empty ───────────────────────────────── */
.empty-state {
display: flex;
@@ -423,5 +426,5 @@ async function submitGenerate() {
&--loading { opacity: 0.6; }
}
.action-btn-text { font-size: 30rpx; font-weight: 700; color: #c9a87c; }
.action-btn-text { font-size: 30rpx; font-weight: 700; color: $primary-dark; }
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,499 +0,0 @@
<template>
<view class="page">
<!-- 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 { 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 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: '09:00',
endTime: '10:00',
capacityStr: '10',
})
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)),
)
})
async function fetchTemplates() {
loading.value = true
try {
templates.value = await adminStore.fetchWeekTemplates()
isDirty.value = false
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
function openAdd() {
editTarget.value = null
form.value = { dayIdx: 0, startTime: '09:00', endTime: '10:00', capacityStr: '10' }
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(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: #c9a87c; }
/* ── 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;
}
@keyframes shimmer {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}
/* ── 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: #c9a87c; }
/* ── 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: #c9a87c; }
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,17 @@
<template>
<view class="booking-page">
<!-- Sticky header area -->
<view class="sticky-header">
<!-- Date selector -->
<DateSelector v-model="selectedDate" @select="onDateSelect" />
<!-- Status bar spacing -->
<view class="status-bar" :style="{ height: statusBarHeight }" />
<!-- Time period filter -->
<TimePeriodFilter v-model="selectedPeriod" @change="onPeriodChange" />
<!-- Page title -->
<view class="page-header">
<text class="page-title">课程预约</text>
</view>
<!-- Date & period filters -->
<view class="filter-header">
<DateSelector v-model="selectedDate" variant="booking" @select="onDateSelect" />
<TimePeriodFilter v-model="selectedPeriod" variant="booking" @change="onPeriodChange" />
</view>
<!-- Slot list -->
@@ -20,24 +25,43 @@
>
<!-- Loading skeleton -->
<view v-if="bookingStore.loadingSlots && !refreshing" class="loading-wrap">
<view v-for="i in 4" :key="i" class="skeleton-card" />
<view v-for="i in 3" :key="i" class="skeleton-card">
<view class="skeleton-time" />
<view class="skeleton-body">
<view class="skeleton-title" />
<view class="skeleton-sub" />
</view>
<view class="skeleton-btn" />
</view>
</view>
<!-- Empty state -->
<view v-else-if="filteredSlots.length === 0" class="empty-wrap">
<image class="empty-img" src="/static/images/empty-calendar.png" mode="aspectFit" />
<view class="empty-illustration">
<view class="empty-circle outer" />
<view class="empty-circle inner" />
<view class="empty-dot" />
</view>
<text class="empty-text">当日暂无可约时段</text>
<text class="empty-sub">请选择其他日期或时段</text>
<text class="empty-sub">请选择其他日期或时段查看</text>
</view>
<!-- Slot cards -->
<view v-else class="slot-list">
<!-- Date summary -->
<view class="date-summary">
<text class="date-summary-text">
{{ filteredSlots.length }} 个可选时段
</text>
</view>
<SlotCard
v-for="slot in filteredSlots"
:key="slot.id"
:slot="slot"
v-for="item in filteredSlots"
:key="item.id"
:time-slot="item"
@book="onBookTap"
@cancel="onCancelTap"
@card-tap="onSlotCardTap"
/>
</view>
@@ -48,7 +72,7 @@
<!-- Confirm popup -->
<BookingConfirmPopup
:visible="showConfirmPopup"
:slot="pendingSlot"
:time-slot="pendingSlot"
:memberships="userStore.activeMemberships as MembershipWithCardType[]"
@confirm="onConfirmBooking"
@cancel="showConfirmPopup = false"
@@ -57,12 +81,15 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pilates/shared'
import { TIME_PERIODS } from '@mp-pilates/shared'
import { BookingStatus, TIME_PERIODS } from '@mp-pilates/shared'
import { useBookingStore } from '../../stores/booking'
import { useUserStore } from '../../stores/user'
import { formatDate, getDateRange } from '../../utils/format'
import { getErrorMessage } from '../../utils/auth'
import { formatDate } from '../../utils/format'
import { getSystemLayout } from '../../utils/system'
import DateSelector from '../../components/DateSelector.vue'
import TimePeriodFilter from '../../components/TimePeriodFilter.vue'
import SlotCard from '../../components/SlotCard.vue'
@@ -81,19 +108,51 @@ const showConfirmPopup = ref(false)
const pendingSlot = ref<TimeSlotWithBookingStatus | null>(null)
const refreshing = ref(false)
// ─── Layout ───────────────────────────────────────────────
// Approximate scroll area height (vh minus sticky header ~220rpx + tabbar ~100rpx)
const scrollHeight = computed(() => {
const sysInfo = uni.getSystemInfoSync()
const headerPx = 220 * (sysInfo.windowWidth / 750)
const tabbarPx = 100 * (sysInfo.windowWidth / 750)
return `${sysInfo.windowHeight - headerPx - tabbarPx}px`
// ─── 微信分享 ───────────────────────────────────────────────
onShareAppMessage(() => {
return {
title: '预约普拉提课程,开启健康新生活',
path: '/pages/booking/index',
imageUrl: '',
}
})
onShareTimeline(() => {
return {
title: '预约普拉提课程,开启健康新生活',
query: '',
}
})
// ─── Layout ───────────────────────────────────────────────
const statusBarHeight = ref('20px')
const scrollHeight = ref('500px')
// Heights of static elements above scroll-view (in rpx, converted to px)
const PAGE_HEADER_RPX = 88 // title bar height
const FILTER_HEADER_RPX = 240 // DateSelector + TimePeriodFilter
const TABBAR_RPX = 100
function updateLayout() {
const { statusBarHeight: statusBarPx, windowWidth } = getSystemLayout()
const ratio = windowWidth / 750
statusBarHeight.value = `${statusBarPx}px`
const headerPx = Math.round(PAGE_HEADER_RPX * ratio)
const filterPx = Math.round(FILTER_HEADER_RPX * ratio)
const tabbarPx = Math.round(TABBAR_RPX * ratio)
// scroll-view fills remaining space: window - statusBar - pageHeader - filters - tabbar
const { windowHeight } = uni.getWindowInfo()
const remaining = windowHeight - statusBarPx - headerPx - filterPx - tabbarPx
scrollHeight.value = `${remaining}px`
}
updateLayout()
// ─── Filtered slots ───────────────────────────────────────
const filteredSlots = computed<TimeSlotWithBookingStatus[]>(() => {
const slots = bookingStore.slots as TimeSlotWithBookingStatus[]
if (!selectedPeriod.value) return [...slots]
if (!selectedPeriod.value) return slots
const period = TIME_PERIODS[selectedPeriod.value]
return slots.filter((slot) => {
@@ -120,11 +179,36 @@ function onDateSelect(date: string) {
}
function onPeriodChange(_period: PeriodKey) {
// Filtering is done client-side via computed property
// No-op: filtering is done client-side via computed property
void _period
}
// ─── Card tap → navigate to detail ───────────────────────
function onSlotCardTap(slot: TimeSlotWithBookingStatus) {
if (slot.isBookedByMe && slot.myBookingId) {
// Already booked → show booking detail
uni.navigateTo({ url: `/pages/booking/detail?id=${slot.myBookingId}` })
} else {
// Not booked → show slot preview with booking action
uni.navigateTo({ url: `/pages/booking/detail?slotId=${slot.id}&date=${slot.date}` })
}
}
// ─── Book flow ────────────────────────────────────────────
async function onBookTap(slot: TimeSlotWithBookingStatus) {
if (slot.isBookedByMe) {
if (slot.myBookingId) {
uni.navigateTo({ url: `/pages/booking/detail?id=${slot.myBookingId}` })
return
}
const title = slot.myBookingStatus === BookingStatus.PENDING_CONFIRMATION
? '该时段已预约,等待老师确认'
: '该时段已预约'
uni.showToast({ title, icon: 'none' })
return
}
// 1. Ensure logged in
if (!userStore.loggedIn) {
uni.showModal({
@@ -134,12 +218,12 @@ async function onBookTap(slot: TimeSlotWithBookingStatus) {
success: async (res) => {
if (res.confirm) {
try {
await userStore.login()
await userStore.fetchMemberships()
// Retry booking flow after login
onBookTap(slot)
} catch {
uni.showToast({ title: '登录失败', icon: 'none' })
const { isNewUser } = await userStore.loginWithSetup()
if (!isNewUser) {
onBookTap(slot)
}
} catch (err: unknown) {
uni.showToast({ title: getErrorMessage(err, '登录失败'), icon: 'none' })
}
}
},
@@ -156,7 +240,9 @@ async function onBookTap(slot: TimeSlotWithBookingStatus) {
cancelText: '取消',
success: (res) => {
if (res.confirm) {
uni.navigateTo({ url: '/pages/store/index' })
// Switch to home tab and auto-scroll to card shop
uni.$emit('scrollToCardShop')
uni.switchTab({ url: '/pages/home/index' })
}
},
})
@@ -225,54 +311,134 @@ onMounted(async () => {
<style lang="scss" scoped>
.booking-page {
min-height: 100vh;
background: #f5f3f0;
height: 100vh;
background:
radial-gradient(circle at top, rgba(255, 232, 218, 0.36), transparent 34%),
linear-gradient(180deg, #fbf7f3 0%, #f6efea 100%);
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ── Sticky header ─────────────────────────────────── */
.sticky-header {
position: sticky;
top: 0;
z-index: 100;
background: #fff;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
/* ── Status bar ───────────────────────────────────── */
.status-bar {
flex-shrink: 0;
background: #fcfaf8;
}
/* ── Page header ──────────────────────────────────── */
.page-header {
flex-shrink: 0;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
background: #fcfaf8;
}
.page-title {
font-size: 34rpx;
font-weight: 700;
color: #3a2e2a;
letter-spacing: 1rpx;
}
/* ── Filter header ────────────────────────────────── */
.filter-header {
flex-shrink: 0;
background: rgba(252, 250, 248, 0.96);
box-shadow: 0 12rpx 30rpx rgba(120, 91, 79, 0.06);
}
/* ── Scroll container ──────────────────────────────── */
.slot-scroll {
flex: 1;
width: 100%;
box-sizing: border-box;
}
/* ── Slot list ─────────────────────────────────────── */
.slot-list {
display: flex;
flex-direction: column;
gap: 20rpx;
padding: 28rpx 24rpx 0;
padding: 28rpx 0 0;
}
/* ── Date summary ──────────────────────────────────── */
.date-summary {
padding: 0 24rpx 16rpx;
}
.date-summary-text {
font-size: 24rpx;
color: #9d8b83;
font-weight: 500;
}
/* ── Loading skeleton ──────────────────────────────── */
.loading-wrap {
display: flex;
flex-direction: column;
gap: 20rpx;
gap: 24rpx;
padding: 28rpx 24rpx;
}
.skeleton-card {
height: 140rpx;
border-radius: 20rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
height: 220rpx;
border-radius: 26rpx;
background: rgba(255, 255, 255, 0.88);
display: flex;
flex-direction: row;
align-items: center;
padding: 28rpx 48rpx;
gap: 20rpx;
margin: 0 24rpx;
box-shadow: 0 16rpx 36rpx rgba(120, 91, 79, 0.08);
}
.skeleton-time {
width: 90rpx;
height: 80rpx;
border-radius: 12rpx;
background: linear-gradient(90deg, rgba(236, 225, 217, 0.9) 25%, rgba(248, 240, 233, 0.98) 50%, rgba(236, 225, 217, 0.9) 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
flex-shrink: 0;
}
.skeleton-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 12rpx;
}
.skeleton-title {
width: 60%;
height: 28rpx;
border-radius: 8rpx;
background: linear-gradient(90deg, rgba(236, 225, 217, 0.9) 25%, rgba(248, 240, 233, 0.98) 50%, rgba(236, 225, 217, 0.9) 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
@keyframes shimmer {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
.skeleton-sub {
width: 40%;
height: 20rpx;
border-radius: 6rpx;
background: linear-gradient(90deg, rgba(236, 225, 217, 0.9) 25%, rgba(248, 240, 233, 0.98) 50%, rgba(236, 225, 217, 0.9) 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
.skeleton-btn {
width: 100rpx;
height: 60rpx;
border-radius: 20rpx;
background: linear-gradient(90deg, rgba(236, 225, 217, 0.9) 25%, rgba(248, 240, 233, 0.98) 50%, rgba(236, 225, 217, 0.9) 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
flex-shrink: 0;
}
/* ── Empty state ───────────────────────────────────── */
@@ -281,26 +447,75 @@ onMounted(async () => {
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 40rpx;
gap: 16rpx;
padding: 120rpx 40rpx 80rpx;
gap: 0;
}
.empty-img {
/* Zen-inspired geometric illustration */
.empty-illustration {
position: relative;
width: 200rpx;
height: 200rpx;
opacity: 0.5;
margin-bottom: 8rpx;
margin-bottom: 56rpx;
}
.empty-circle {
position: absolute;
border-radius: 50%;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
&.outer {
width: 180rpx;
height: 180rpx;
border: 2rpx solid rgba(192, 154, 137, 0.18);
animation: breathe 3s ease-in-out infinite;
}
&.inner {
width: 120rpx;
height: 120rpx;
background: linear-gradient(135deg, #f6e9e1 0%, #ddc1b4 50%, #b98f7d 100%);
opacity: 0.6;
animation: breathe 3s ease-in-out infinite 0.5s;
}
}
.empty-dot {
position: absolute;
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background: #a87d6c;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
animation: pulse 2s ease-in-out infinite;
}
.empty-text {
font-size: 30rpx;
color: #666;
font-size: 32rpx;
color: #6f605b;
font-weight: 600;
letter-spacing: 2rpx;
margin-bottom: 16rpx;
}
.empty-sub {
font-size: 26rpx;
color: #bbb;
color: #a18a82;
letter-spacing: 1rpx;
}
@keyframes breathe {
0%, 100% { transform: translate(-50%, -50%) scale(1); opacity: 0.6; }
50% { transform: translate(-50%, -50%) scale(1.05); opacity: 0.4; }
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
50% { opacity: 0.5; transform: translate(-50%, -50%) scale(0.8); }
}
/* ── Bottom spacer ─────────────────────────────────── */

View File

@@ -1,5 +1,6 @@
<template>
<view class="card-detail-page">
<view class="card-detail-page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="购买会员卡" show-back />
<!-- Loading state -->
<view v-if="loading" class="loading-wrap">
<view class="skeleton-header" />
@@ -11,7 +12,7 @@
</view>
<!-- Error state -->
<view v-else-if="!card" class="error-wrap">
<view v-else-if="!card && !showAll" class="error-wrap">
<text class="error-icon">😕</text>
<text class="error-text">会员卡信息加载失败</text>
<view class="retry-btn" @tap="loadCard">
@@ -19,26 +20,97 @@
</view>
</view>
<!-- Card content -->
<!-- All cards list mode -->
<template v-else-if="showAll">
<view v-if="loading" class="loading-wrap">
<view class="skeleton-header" />
<view class="skeleton-body">
<view class="skeleton-line w80" />
<view class="skeleton-line w60" />
<view class="skeleton-line w40" />
</view>
</view>
<view v-else-if="allCards.length" class="all-cards-list">
<view
v-for="c in allCards"
:key="c.id"
class="card-row"
@tap="goToDetail(c.id)"
>
<!-- Card Cover image if available, gradient fallback -->
<view class="card-cover" :class="c.coverUrl ? '' : getCardCoverClass(c.type)">
<image
v-if="c.coverUrl"
class="card-cover-img"
:src="c.coverUrl"
mode="aspectFill"
/>
<template v-else>
<view class="cover-deco cover-deco--1" />
<view class="cover-deco cover-deco--2" />
</template>
</view>
<!-- Card info aligns with card-cover height -->
<view class="card-info">
<view class="info-top">
<text class="card-name">{{ c.name }}</text>
<text class="card-validity">有效期 {{ c.durationDays }} </text>
</view>
<view class="info-bottom">
<view v-if="c.totalTimes" class="card-times">
<text class="card-times-value">{{ c.totalTimes }}</text>
<text class="card-times-unit">课时</text>
</view>
<view class="price-row">
<text class="price-current">¥{{ formatPrice(c.price) }}</text>
<text
v-if="c.originalPrice && c.originalPrice > c.price"
class="price-original"
>
¥{{ formatPrice(c.originalPrice) }}
</text>
</view>
</view>
</view>
<text class="card-arrow"></text>
</view>
</view>
<view v-else class="empty-state">
<text class="empty-text">暂无可购买的会员卡</text>
</view>
</template>
<!-- Card content (single card mode) -->
<template v-else>
<!-- Hero section -->
<view class="card-hero" :class="heroClass">
<!-- Decorative circles -->
<view class="hero-deco hero-deco--1" />
<view class="hero-deco hero-deco--2" />
<view class="card-hero" :class="cardData.coverUrl ? 'hero--custom' : heroClass">
<!-- Cover image background -->
<image
v-if="cardData.coverUrl"
class="hero-cover-img"
:src="cardData.coverUrl"
mode="aspectFill"
/>
<!-- Decorative circles (only when no cover image) -->
<template v-else>
<view class="hero-deco hero-deco--1" />
<view class="hero-deco hero-deco--2" />
</template>
<view class="hero-badge">
<text class="hero-badge-text">{{ typeLabel }}</text>
</view>
<text class="hero-name">{{ card.name }}</text>
<text class="hero-name">{{ cardData.name }}</text>
<view class="hero-price-row">
<text class="hero-currency">¥</text>
<text class="hero-price">{{ formatPrice(card.price) }}</text>
<text class="hero-price">{{ formatPrice(cardData.price) }}</text>
<text
v-if="card.originalPrice && card.originalPrice > card.price"
v-if="cardData.originalPrice && cardData.originalPrice > cardData.price"
class="hero-original"
>
¥{{ formatPrice(card.originalPrice) }}
¥{{ formatPrice(cardData.originalPrice) }}
</text>
</view>
</view>
@@ -48,28 +120,28 @@
<!-- Key info grid -->
<view class="info-card">
<view class="info-grid">
<view class="info-cell" v-if="card.totalTimes">
<text class="cell-value">{{ card.totalTimes }}</text>
<view class="info-cell" v-if="cardData.totalTimes">
<text class="cell-value">{{ cardData.totalTimes }}</text>
<text class="cell-label">课时次数</text>
</view>
<view class="info-cell">
<text class="cell-value">{{ card.durationDays }}</text>
<text class="cell-value">{{ cardData.durationDays }}</text>
<text class="cell-label">有效天数</text>
</view>
<view class="info-cell">
<text class="cell-value">{{ unitPrice }}</text>
<text class="cell-label">{{ card.totalTimes ? '每次单价' : '按天均价' }}</text>
<text class="cell-label">{{ cardData.totalTimes ? '每次单价' : '按天均价' }}</text>
</view>
</view>
</view>
<!-- Description -->
<view v-if="card.description" class="desc-card">
<view v-if="cardData.description" class="desc-card">
<view class="section-header">
<view class="section-dot" />
<text class="section-title">课程说明</text>
</view>
<text class="desc-content">{{ card.description }}</text>
<text class="desc-content">{{ cardData.description }}</text>
</view>
<!-- Features list -->
@@ -80,13 +152,13 @@
</view>
<view class="feature-item">
<text class="feature-dot"></text>
<text class="feature-text">购买后立即生效有效期 {{ card.durationDays }} </text>
<text class="feature-text">购买后立即生效有效期 {{ cardData.durationDays }} </text>
</view>
<view v-if="card.totalTimes" class="feature-item">
<view v-if="cardData.totalTimes" class="feature-item">
<text class="feature-dot"></text>
<text class="feature-text"> {{ card.totalTimes }} 次课时可灵活安排上课时间</text>
<text class="feature-text"> {{ cardData.totalTimes }} 次课时可灵活安排上课时间</text>
</view>
<view v-if="!card.totalTimes" class="feature-item">
<view v-if="!cardData.totalTimes" class="feature-item">
<text class="feature-dot"></text>
<text class="feature-text">有效期内可无限次预约课程</text>
</view>
@@ -109,7 +181,7 @@
<view class="bottom-bar">
<view class="price-summary">
<text class="summary-label">实付金额</text>
<text class="summary-price">¥{{ formatPrice(card.price) }}</text>
<text class="summary-price">¥{{ formatPrice(cardData.price) }}</text>
</view>
<view
class="buy-btn"
@@ -127,18 +199,27 @@
import { ref, computed, onMounted } from 'vue'
import type { CardType, CreateOrderResponse } from '@mp-pilates/shared'
import { CardTypeCategory } from '@mp-pilates/shared'
import { getErrorMessage } from '../../utils/auth'
import { get, post } from '../../utils/request'
import { formatPrice } from '../../utils/format'
import { formatPrice, getCardTypeLabel, getCardCoverClass } from '../../utils/format'
import { getSystemLayout } from '../../utils/system'
import { useUserStore } from '../../stores/user'
import { requestOrderPaidSubscriptionMessage } from '../../utils/wechat-subscription'
import CustomNavBar from '../../components/CustomNavBar.vue'
const userStore = useUserStore()
// ─── Nav bar height ──────────────────────────────────────────
const navBarHeight = ref('64px')
// ─── Route params ──────────────────────────────────────────
const cardId = ref<string>('')
const isTrial = ref(false)
const showAll = ref(false)
// ─── State ────────────────────────────────────────────────
const card = ref<CardType | null>(null)
const allCards = ref<CardType[]>([])
const loading = ref(false)
const buying = ref(false)
@@ -170,12 +251,19 @@ const unitPrice = computed(() => {
return `¥${(pricePerDay / 100).toFixed(0)}`
})
const cardData = computed<CardType>(() => card.value as CardType)
// ─── Data loading ─────────────────────────────────────────
async function loadCard() {
loading.value = true
try {
const types = await get<CardType[]>('/membership/card-types')
const activeTypes = types.filter((c) => c.isActive)
const activeTypes = types.filter((c) => c.isActive).sort((a, b) => a.sortOrder - b.sortOrder)
if (showAll.value) {
allCards.value = activeTypes
return
}
if (isTrial.value) {
// Auto-find the trial card type
@@ -187,12 +275,20 @@ async function loadCard() {
card.value = activeTypes.find((c) => c.id === cardId.value) ?? null
}
} catch {
card.value = null
if (!showAll.value) {
card.value = null
}
allCards.value = []
} finally {
loading.value = false
}
}
// ─── Helpers ───────────────────────────────────────────────
function goToDetail(id: string) {
uni.navigateTo({ url: `/pages/card/detail?id=${id}` })
}
// ─── Buy flow ─────────────────────────────────────────────
async function handleBuy() {
if (buying.value || !card.value) return
@@ -206,10 +302,12 @@ async function handleBuy() {
success: async (res) => {
if (res.confirm) {
try {
await userStore.login()
handleBuy()
} catch {
uni.showToast({ title: '登录失败', icon: 'none' })
const { isNewUser } = await userStore.loginWithSetup()
if (!isNewUser) {
handleBuy()
}
} catch (err: unknown) {
uni.showToast({ title: getErrorMessage(err, '登录失败'), icon: 'none' })
}
}
},
@@ -234,8 +332,10 @@ async function doPurchase() {
uni.showLoading({ title: '创建订单...' })
try {
const inviterId = uni.getStorageSync('invite_inviter_id') as string
const result = await post<CreateOrderResponse>('/payment/create-order', {
cardTypeId: card.value.id,
inviterId: isTrial.value && inviterId ? inviterId : undefined,
})
uni.hideLoading()
@@ -255,6 +355,7 @@ async function doPurchase() {
})
// Payment succeeded — refresh memberships then navigate
await requestOrderPaidSubscriptionMessage().catch(() => undefined)
uni.showToast({ title: '购买成功!', icon: 'success' })
await userStore.fetchMemberships()
setTimeout(() => {
@@ -273,11 +374,14 @@ async function doPurchase() {
// ─── 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 ?? {}
cardId.value = options.id ?? ''
isTrial.value = options.trial === '1'
showAll.value = options.showAll === '1'
loadCard()
})
</script>
@@ -320,11 +424,6 @@ onMounted(() => {
&.w40 { width: 40%; }
}
@keyframes shimmer {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}
/* ── Error ───────────────────────────────────────────── */
.error-wrap {
display: flex;
@@ -347,7 +446,7 @@ onMounted(() => {
.retry-btn {
padding: 20rpx 48rpx;
border-radius: 40rpx;
background: #c9a87c;
background: $primary-dark;
}
.retry-text {
@@ -366,23 +465,35 @@ onMounted(() => {
overflow: hidden;
&.hero--times {
background: linear-gradient(135deg, #1a1a2e 0%, #2d2d5e 100%);
background: linear-gradient(135deg, #E8D5C4 0%, #D4BFA8 100%);
}
&.hero--duration {
background: linear-gradient(135deg, #6c3483 0%, #9b59b6 100%);
background: linear-gradient(135deg, #D8C8DC 0%, #C4AECB 100%);
}
&.hero--trial {
background: linear-gradient(135deg, #7d6608 0%, #c9a87c 100%);
background: linear-gradient(135deg, #C8D8D2 0%, #A9C4BC 100%);
}
&.hero--custom {
background: #333;
}
}
.hero-cover-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0.5;
}
/* Decorative background circles */
.hero-deco {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.05);
background: rgba(255, 255, 255, 0.35);
pointer-events: none;
&--1 {
@@ -404,14 +515,14 @@ onMounted(() => {
align-self: flex-start;
padding: 8rpx 22rpx;
border-radius: 20rpx;
background: rgba(255, 255, 255, 0.18);
border: 1rpx solid rgba(255, 255, 255, 0.3);
background: rgba(74, 64, 53, 0.1);
border: 1rpx solid rgba(74, 64, 53, 0.15);
z-index: 1;
}
.hero-badge-text {
font-size: 22rpx;
color: #fff;
color: $brand-color;
font-weight: 600;
letter-spacing: 1rpx;
}
@@ -419,7 +530,7 @@ onMounted(() => {
.hero-name {
font-size: 48rpx;
font-weight: 800;
color: #fff;
color: $brand-color;
letter-spacing: 1rpx;
z-index: 1;
}
@@ -434,20 +545,20 @@ onMounted(() => {
.hero-currency {
font-size: 28rpx;
font-weight: 600;
color: rgba(255, 255, 255, 0.85);
color: rgba(74, 64, 53, 0.7);
line-height: 1;
}
.hero-price {
font-size: 64rpx;
font-weight: 800;
color: #fff;
color: $brand-color;
line-height: 1;
}
.hero-original {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.5);
color: rgba(74, 64, 53, 0.4);
text-decoration: line-through;
margin-left: 8rpx;
}
@@ -473,14 +584,14 @@ onMounted(() => {
width: 6rpx;
height: 28rpx;
border-radius: 3rpx;
background: #c9a87c;
background: $primary-dark;
flex-shrink: 0;
}
.section-title {
font-size: 30rpx;
font-weight: 700;
color: #1a1a1a;
color: $text-primary;
}
/* ── Info grid card ──────────────────────────────────── */
@@ -557,7 +668,7 @@ onMounted(() => {
.feature-dot {
font-size: 26rpx;
color: #c9a87c;
color: $primary-dark;
line-height: 1.65;
flex-shrink: 0;
}
@@ -598,7 +709,7 @@ onMounted(() => {
.summary-price {
font-size: 40rpx;
font-weight: 800;
color: #c9a87c;
color: $primary-dark;
}
.buy-btn {
@@ -623,7 +734,175 @@ onMounted(() => {
.buy-btn-text {
font-size: 32rpx;
font-weight: 700;
color: #c9a87c;
color: $primary-dark;
letter-spacing: 2rpx;
}
/* ── All cards list ────────────────────────────────────── */
.all-cards-list {
padding: 24rpx;
display: flex;
flex-direction: column;
gap: 20rpx;
}
.card-row {
display: flex;
align-items: center;
gap: 20rpx;
padding: 20rpx;
background: #fff;
border-radius: 20rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
}
/* ══════════════════════════════════════════════════════════
CARD COVER — Clean minimal design
══════════════════════════════════════════════════════════ */
.card-cover {
width: 200rpx;
height: 130rpx;
border-radius: 16rpx;
overflow: hidden;
flex-shrink: 0;
position: relative;
}
.card-cover-img {
width: 100%;
height: 100%;
}
.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);
}
}
.cover--times {
background: linear-gradient(135deg, #E8D5C4 0%, #D4BFA8 100%);
}
.cover--duration {
background: linear-gradient(135deg, #D8C8DC 0%, #C4AECB 100%);
}
.cover--trial {
background: linear-gradient(135deg, #C8D8D2 0%, #A9C4BC 100%);
}
/* ── Card info — matches card-cover height ── */
.card-info {
flex: 1;
min-width: 0;
height: 130rpx;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.info-top {
display: flex;
flex-direction: column;
justify-content: center;
gap: 4rpx;
}
.info-bottom {
display: flex;
align-items: baseline;
gap: 16rpx;
}
.card-name {
font-size: 30rpx;
font-weight: 700;
color: $text-primary;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
letter-spacing: 0.5rpx;
line-height: 1.2;
}
.card-validity {
font-size: 23rpx;
color: $text-secondary;
line-height: 1.2;
}
.card-times {
display: flex;
align-items: baseline;
gap: 4rpx;
}
.card-times-value {
font-size: 34rpx;
font-weight: 800;
color: $brand-color;
line-height: 1;
}
.card-times-unit {
font-size: 20rpx;
color: $text-secondary;
font-weight: 500;
}
.price-row {
display: flex;
align-items: baseline;
gap: 6rpx;
}
.price-current {
font-size: 32rpx;
font-weight: 800;
color: $brand-color;
line-height: 1;
}
.price-original {
font-size: 20rpx;
color: $text-hint;
text-decoration: line-through;
}
.card-arrow {
font-size: 44rpx;
color: $text-hint;
flex-shrink: 0;
transform: scaleX(0.5);
transform-origin: center;
}
/* ── Empty state ─────────────────────────────────────── */
.empty-state {
padding: 160rpx 40rpx;
display: flex;
align-items: center;
justify-content: center;
}
.empty-text {
font-size: 28rpx;
color: #bbb;
}
</style>

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
<template>
<view class="bookings-page">
<view class="bookings-page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="我的预约" show-back />
<!-- Tab bar -->
<view class="tab-bar">
<view
@@ -27,16 +28,27 @@
>
<!-- Loading -->
<view v-if="bookingStore.loadingBookings && !refreshingUpcoming" class="loading-wrap">
<view v-for="i in 3" :key="i" class="skeleton-card" />
<view v-for="i in 3" :key="i" class="skeleton-card">
<view class="skeleton-stripe" />
<view class="skeleton-body">
<view class="skeleton-line skeleton-line--long" />
<view class="skeleton-line skeleton-line--short" />
<view class="skeleton-line skeleton-line--medium" />
</view>
</view>
</view>
<!-- Empty -->
<view v-else-if="upcomingBookings.length === 0" class="empty-wrap">
<text class="empty-icon">📅</text>
<view class="empty-illustration">
<view class="empty-circle outer" />
<view class="empty-circle inner" />
<view class="empty-dot" />
</view>
<text class="empty-title">暂无即将上课的预约</text>
<text class="empty-sub">去预约一节课</text>
<text class="empty-sub">开始预约你的普拉提课程</text>
<view class="empty-btn" @tap="goBooking">
<text class="empty-btn-text">预约</text>
<text class="empty-btn-text">立即预约</text>
</view>
</view>
@@ -46,28 +58,37 @@
v-for="booking in upcomingBookings"
:key="booking.id"
class="booking-card"
@tap="goDetail(booking)"
>
<view class="booking-stripe stripe--confirmed" />
<view class="booking-stripe" :class="stripeClass(booking.status)" />
<view class="booking-content">
<view class="booking-main">
<view class="booking-header">
<view class="booking-datetime">
<text class="booking-date">{{ formatDateDisplay(booking.timeSlot.date) }}</text>
<text class="booking-time">
{{ booking.timeSlot.startTime.slice(0, 5) }} {{ booking.timeSlot.endTime.slice(0, 5) }}
{{ booking.timeSlot.startTime.slice(0, 5) }} - {{ booking.timeSlot.endTime.slice(0, 5) }}
</text>
</view>
<view class="status-badge badge--confirmed">
<text class="status-text">已预约</text>
<view class="status-badge" :class="statusBadgeClass(booking.status)">
<text class="status-text">{{ statusLabel(booking.status) }}</text>
</view>
</view>
<view class="booking-meta">
<text class="meta-text">💳 {{ booking.membership.cardType.name }}</text>
</view>
<view class="cancel-row">
<view class="cancel-btn" @tap="handleCancel(booking)">
<view v-if="booking.status !== 'PENDING_CONFIRMATION'" class="booking-footer">
<view class="booking-meta">
<text class="meta-label">使用卡种</text>
<text class="meta-value">{{ booking.membership.cardType.name }}</text>
</view>
<view class="cancel-btn" @tap.stop="handleCancel(booking)">
<text class="cancel-text">取消预约</text>
</view>
</view>
<view v-else class="booking-footer">
<view class="booking-meta">
<text class="meta-label">使用卡种</text>
<text class="meta-value">{{ booking.membership.cardType.name }}</text>
</view>
<text class="pending-hint">等待老师确认</text>
</view>
</view>
</view>
</view>
@@ -86,12 +107,22 @@
>
<!-- Loading -->
<view v-if="bookingStore.loadingBookings && !refreshingHistory" class="loading-wrap">
<view v-for="i in 3" :key="i" class="skeleton-card" />
<view v-for="i in 3" :key="i" class="skeleton-card">
<view class="skeleton-stripe" />
<view class="skeleton-body">
<view class="skeleton-line skeleton-line--long" />
<view class="skeleton-line skeleton-line--short" />
</view>
</view>
</view>
<!-- Empty -->
<view v-else-if="historyBookings.length === 0" class="empty-wrap">
<text class="empty-icon">📋</text>
<view class="empty-illustration">
<view class="empty-circle outer" />
<view class="empty-circle inner" />
<view class="empty-dot" />
</view>
<text class="empty-title">暂无历史记录</text>
<text class="empty-sub">已完成或取消的课程将显示在这里</text>
</view>
@@ -102,22 +133,24 @@
v-for="booking in historyBookings"
:key="booking.id"
class="booking-card"
@tap="goDetail(booking)"
>
<view class="booking-stripe" :class="stripeClass(booking.status)" />
<view class="booking-content">
<view class="booking-main">
<view class="booking-header">
<view class="booking-datetime">
<text class="booking-date">{{ formatDateDisplay(booking.timeSlot.date) }}</text>
<text class="booking-time">
{{ booking.timeSlot.startTime.slice(0, 5) }} {{ booking.timeSlot.endTime.slice(0, 5) }}
{{ booking.timeSlot.startTime.slice(0, 5) }} - {{ booking.timeSlot.endTime.slice(0, 5) }}
</text>
</view>
<view class="status-badge" :class="statusBadgeClass(booking.status)">
<text class="status-text">{{ statusLabel(booking.status) }}</text>
</view>
</view>
<view class="booking-meta">
<text class="meta-text">💳 {{ booking.membership.cardType.name }}</text>
<view class="booking-meta-row">
<text class="meta-label">使用卡种</text>
<text class="meta-value">{{ booking.membership.cardType.name }}</text>
</view>
</view>
</view>
@@ -134,9 +167,13 @@ import type { BookingWithDetails } from '@mp-pilates/shared'
import { BookingStatus } from '@mp-pilates/shared'
import { useBookingStore } from '../../stores/booking'
import { formatDate, getWeekdayLabel } from '../../utils/format'
import CustomNavBar from '../../components/CustomNavBar.vue'
const bookingStore = useBookingStore()
// ─── Nav bar height ──────────────────────────────────────
const navBarHeight = ref('64px')
// ─── Tab state ────────────────────────────────────────────
type TabKey = 'upcoming' | 'history'
@@ -149,34 +186,49 @@ const activeTab = ref<TabKey>('upcoming')
const refreshingUpcoming = ref(false)
const refreshingHistory = ref(false)
// ─── Safe array accessor ─────────────────────────────────
function safeBookings(): readonly BookingWithDetails[] {
const raw = bookingStore.myBookings
return Array.isArray(raw) ? raw : []
}
/** Normalize date to YYYY-MM-DD — handles both "2026-04-06" and "2026-04-06T00:00:00.000Z" */
function toDateStr(date: string): string {
return date.slice(0, 10)
}
// ─── Filtered bookings ────────────────────────────────────
const today = computed(() => formatDate(new Date()))
const upcomingBookings = computed<BookingWithDetails[]>(() => {
const all = bookingStore.myBookings as BookingWithDetails[]
return all
return safeBookings()
.filter(
(b) => b.status === BookingStatus.CONFIRMED && b.timeSlot.date >= today.value,
(b) =>
(b.status === BookingStatus.PENDING_CONFIRMATION || b.status === BookingStatus.CONFIRMED) &&
toDateStr(b.timeSlot.date) >= today.value,
)
.sort((a, b) => {
if (a.timeSlot.date !== b.timeSlot.date) {
return a.timeSlot.date.localeCompare(b.timeSlot.date)
const dateA = toDateStr(a.timeSlot.date)
const dateB = toDateStr(b.timeSlot.date)
if (dateA !== dateB) {
return dateA.localeCompare(dateB)
}
return a.timeSlot.startTime.localeCompare(b.timeSlot.startTime)
})
})
const historyBookings = computed<BookingWithDetails[]>(() => {
const all = bookingStore.myBookings as BookingWithDetails[]
return all
return safeBookings()
.filter(
(b) =>
b.status !== BookingStatus.CONFIRMED ||
b.timeSlot.date < today.value,
toDateStr(b.timeSlot.date) < today.value,
)
.sort((a, b) => {
if (b.timeSlot.date !== a.timeSlot.date) {
return b.timeSlot.date.localeCompare(a.timeSlot.date)
const dateA = toDateStr(a.timeSlot.date)
const dateB = toDateStr(b.timeSlot.date)
if (dateB !== dateA) {
return dateB.localeCompare(dateA)
}
return b.timeSlot.startTime.localeCompare(a.timeSlot.startTime)
})
@@ -185,43 +237,61 @@ const historyBookings = computed<BookingWithDetails[]>(() => {
const upcomingCount = computed(() => upcomingBookings.value.length)
// ─── Helpers ──────────────────────────────────────────────
function statusLabel(status: BookingStatus): string {
const map: Record<BookingStatus, string> = {
[BookingStatus.CONFIRMED]: '已预约',
[BookingStatus.CANCELLED]: '已取消',
[BookingStatus.COMPLETED]: '已完成',
[BookingStatus.NO_SHOW]: '未出席',
}
return map[status] ?? status
const STATUS_LABELS: Record<string, string> = {
[BookingStatus.PENDING_CONFIRMATION]: '待确认',
[BookingStatus.CONFIRMED]: '已预约',
[BookingStatus.CANCELLED]: '已取消',
[BookingStatus.COMPLETED]: '已完成',
[BookingStatus.NO_SHOW]: '未出席',
}
function statusBadgeClass(status: BookingStatus): string {
const map: Record<BookingStatus, string> = {
[BookingStatus.CONFIRMED]: 'badge--confirmed',
[BookingStatus.CANCELLED]: 'badge--cancelled',
[BookingStatus.COMPLETED]: 'badge--completed',
[BookingStatus.NO_SHOW]: 'badge--noshow',
}
return map[status] ?? ''
const STATUS_BADGE_CLASSES: Record<string, string> = {
[BookingStatus.PENDING_CONFIRMATION]: 'badge--pending',
[BookingStatus.CONFIRMED]: 'badge--confirmed',
[BookingStatus.CANCELLED]: 'badge--cancelled',
[BookingStatus.COMPLETED]: 'badge--completed',
[BookingStatus.NO_SHOW]: 'badge--noshow',
}
function stripeClass(status: BookingStatus): string {
const map: Record<BookingStatus, string> = {
[BookingStatus.CONFIRMED]: 'stripe--confirmed',
[BookingStatus.CANCELLED]: 'stripe--cancelled',
[BookingStatus.COMPLETED]: 'stripe--completed',
[BookingStatus.NO_SHOW]: 'stripe--noshow',
}
return map[status] ?? ''
const STATUS_STRIPE_CLASSES: Record<string, string> = {
[BookingStatus.PENDING_CONFIRMATION]: 'stripe--pending',
[BookingStatus.CONFIRMED]: 'stripe--confirmed',
[BookingStatus.CANCELLED]: 'stripe--cancelled',
[BookingStatus.COMPLETED]: 'stripe--completed',
[BookingStatus.NO_SHOW]: 'stripe--noshow',
}
function statusLabel(status: string): string {
return STATUS_LABELS[status] ?? status
}
function statusBadgeClass(status: string): string {
return STATUS_BADGE_CLASSES[status] ?? ''
}
function stripeClass(status: string): string {
return STATUS_STRIPE_CLASSES[status] ?? ''
}
function formatDateDisplay(dateStr: string): string {
// e.g. "2024-03-15" → "3月15日 周五"
const d = new Date(dateStr)
const month = d.getMonth() + 1
const day = d.getDate()
const weekday = getWeekdayLabel(d)
return `${month}${day}${weekday}`
const normalized = toDateStr(dateStr)
const todayStr = formatDate(new Date())
const tomorrowDate = new Date()
tomorrowDate.setDate(tomorrowDate.getDate() + 1)
const tomorrowStr = formatDate(tomorrowDate)
// Parse from normalized YYYY-MM-DD to avoid timezone shifts
const [y, m, d] = normalized.split('-').map(Number)
const localDate = new Date(y, m - 1, d)
const weekday = getWeekdayLabel(localDate)
if (normalized === todayStr) {
return `今天 ${m}${d}`
}
if (normalized === tomorrowStr) {
return `明天 ${m}${d}`
}
return `${m}${d}${weekday}`
}
// ─── Actions ──────────────────────────────────────────────
@@ -245,6 +315,10 @@ function goBooking() {
uni.switchTab({ url: '/pages/booking/index' })
}
function goDetail(booking: BookingWithDetails) {
uni.navigateTo({ url: `/pages/booking/detail?id=${booking.id}` })
}
async function handleCancel(booking: BookingWithDetails) {
const dateLabel = formatDateDisplay(booking.timeSlot.date)
const timeLabel = booking.timeSlot.startTime.slice(0, 5)
@@ -273,7 +347,12 @@ async function handleCancel(booking: BookingWithDetails) {
}
// ─── Lifecycle ────────────────────────────────────────────
onMounted(() => bookingStore.fetchMyBookings())
onMounted(() => {
const windowInfo = uni.getWindowInfo()
const statusBarH = windowInfo.statusBarHeight ?? 20
navBarHeight.value = `${statusBarH + Math.round(88 * windowInfo.windowWidth / 750)}px`
bookingStore.fetchMyBookings()
})
</script>
<style lang="scss" scoped>
@@ -302,10 +381,15 @@ onMounted(() => bookingStore.fetchMyBookings())
gap: 8rpx;
padding: 28rpx 0;
position: relative;
transition: opacity 0.2s;
&:active {
opacity: 0.7;
}
&.active {
.tab-label {
color: #c9a87c;
color: $primary-dark;
font-weight: 600;
}
@@ -317,7 +401,7 @@ onMounted(() => bookingStore.fetchMyBookings())
transform: translateX(-50%);
width: 48rpx;
height: 4rpx;
background: #c9a87c;
background: $primary-dark;
border-radius: 2rpx;
}
}
@@ -352,7 +436,7 @@ onMounted(() => bookingStore.fetchMyBookings())
height: calc(100vh - 88rpx);
}
/* ── Loading ─────────────────────────────────────────── */
/* ── Loading skeleton ────────────────────────────────── */
.loading-wrap {
padding: 24rpx;
display: flex;
@@ -361,16 +445,37 @@ onMounted(() => bookingStore.fetchMyBookings())
}
.skeleton-card {
height: 160rpx;
border-radius: 16rpx;
background: #fff;
overflow: hidden;
display: flex;
flex-direction: row;
}
.skeleton-stripe {
width: 8rpx;
flex-shrink: 0;
background: #eee;
}
.skeleton-body {
flex: 1;
padding: 28rpx 24rpx;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.skeleton-line {
height: 28rpx;
border-radius: 8rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
@keyframes shimmer {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
&--long { width: 70%; }
&--short { width: 40%; }
&--medium { width: 55%; }
}
/* ── Empty ───────────────────────────────────────────── */
@@ -380,35 +485,93 @@ onMounted(() => bookingStore.fetchMyBookings())
align-items: center;
justify-content: center;
padding: 120rpx 40rpx;
gap: 20rpx;
gap: 0;
}
.empty-icon {
font-size: 80rpx;
.empty-illustration {
position: relative;
width: 200rpx;
height: 200rpx;
margin-bottom: 56rpx;
}
.empty-circle {
position: absolute;
border-radius: 50%;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
&.outer {
width: 180rpx;
height: 180rpx;
border: 2rpx solid $primary-border;
animation: breathe 3s ease-in-out infinite;
}
&.inner {
width: 120rpx;
height: 120rpx;
background: linear-gradient(135deg, $primary-light 0%, $primary-color 50%, $primary-dark 100%);
opacity: 0.6;
animation: breathe 3s ease-in-out infinite 0.5s;
}
}
.empty-dot {
position: absolute;
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background: $primary-dark;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
animation: pulse 2s ease-in-out infinite;
}
.empty-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
color: $primary-dark;
letter-spacing: 2rpx;
margin-bottom: 16rpx;
}
.empty-sub {
font-size: 26rpx;
color: #999;
color: $primary-color;
letter-spacing: 1rpx;
}
@keyframes breathe {
0%, 100% { transform: translate(-50%, -50%) scale(1); opacity: 0.6; }
50% { transform: translate(-50%, -50%) scale(1.05); opacity: 0.4; }
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
50% { opacity: 0.5; transform: translate(-50%, -50%) scale(0.8); }
}
.empty-btn {
margin-top: 12rpx;
padding: 20rpx 56rpx;
margin-top: 24rpx;
padding: 22rpx 64rpx;
border-radius: 44rpx;
background: #c9a87c;
background: linear-gradient(135deg, $primary-color, $primary-dark);
box-shadow: 0 4rpx 16rpx rgba(201, 168, 124, 0.3);
&:active {
opacity: 0.85;
transform: scale(0.98);
}
}
.empty-btn-text {
font-size: 30rpx;
color: #fff;
font-weight: 600;
letter-spacing: 2rpx;
}
/* ── List ────────────────────────────────────────────── */
@@ -416,15 +579,15 @@ onMounted(() => bookingStore.fetchMyBookings())
padding: 24rpx 24rpx 0;
display: flex;
flex-direction: column;
gap: 16rpx;
gap: 20rpx;
}
/* ── Booking card ────────────────────────────────────── */
.booking-card {
background: #fff;
border-radius: 16rpx;
border-radius: 20rpx;
overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
box-shadow: 0 2rpx 16rpx rgba(0, 0, 0, 0.05);
display: flex;
flex-direction: row;
}
@@ -434,21 +597,22 @@ onMounted(() => bookingStore.fetchMyBookings())
width: 8rpx;
flex-shrink: 0;
&.stripe--confirmed { background: #c9a87c; }
&.stripe--completed { background: #4caf50; }
&.stripe--pending { background: #f59e0b; }
&.stripe--confirmed { background: $primary-dark; }
&.stripe--completed { background: #66bb6a; }
&.stripe--cancelled { background: #e0e0e0; }
&.stripe--noshow { background: #ef4444; }
&.stripe--noshow { background: #ef5350; }
}
.booking-content {
flex: 1;
padding: 24rpx 24rpx 24rpx 20rpx;
padding: 28rpx 24rpx 24rpx 20rpx;
display: flex;
flex-direction: column;
gap: 12rpx;
gap: 16rpx;
}
.booking-main {
.booking-header {
display: flex;
flex-direction: row;
align-items: flex-start;
@@ -462,14 +626,15 @@ onMounted(() => bookingStore.fetchMyBookings())
}
.booking-date {
font-size: 28rpx;
font-size: 30rpx;
font-weight: 600;
color: #1a1a1a;
}
.booking-time {
font-size: 24rpx;
font-size: 26rpx;
color: #888;
letter-spacing: 1rpx;
}
/* Status badge */
@@ -478,45 +643,69 @@ onMounted(() => bookingStore.fetchMyBookings())
border-radius: 20rpx;
flex-shrink: 0;
&.badge--confirmed { background: #fff8ee; }
&.badge--completed { background: #f0faf3; }
&.badge--cancelled { background: #f5f5f5; }
&.badge--noshow { background: #fef0f0; }
&.badge--pending { background: rgba(245, 158, 11, 0.12); }
&.badge--confirmed { background: rgba(201, 168, 124, 0.12); }
&.badge--completed { background: rgba(102, 187, 106, 0.12); }
&.badge--cancelled { background: rgba(0, 0, 0, 0.04); }
&.badge--noshow { background: rgba(239, 83, 80, 0.1); }
}
.status-text {
font-size: 22rpx;
font-weight: 600;
.badge--confirmed & { color: #c9a87c; }
.badge--completed & { color: #4caf50; }
.badge--pending & { color: #f59e0b; }
.badge--confirmed & { color: $primary-dark; }
.badge--completed & { color: #66bb6a; }
.badge--cancelled & { color: #bbb; }
.badge--noshow & { color: #ef4444; }
.badge--noshow & { color: #ef5350; }
}
/* Footer row with meta + cancel */
.booking-footer {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding-top: 8rpx;
border-top: 1rpx solid #f5f5f5;
}
/* Meta info */
.booking-meta {
.meta-text {
font-size: 24rpx;
color: #999;
}
}
/* Cancel row */
.cancel-row {
.booking-meta,
.booking-meta-row {
display: flex;
justify-content: flex-end;
margin-top: 4rpx;
flex-direction: row;
align-items: center;
gap: 8rpx;
}
.booking-meta-row {
padding-top: 8rpx;
border-top: 1rpx solid #f5f5f5;
}
.meta-label {
font-size: 22rpx;
color: #bbb;
}
.meta-value {
font-size: 24rpx;
color: #666;
font-weight: 500;
}
/* Cancel button */
.cancel-btn {
padding: 10rpx 24rpx;
padding: 12rpx 28rpx;
border-radius: 24rpx;
border: 1rpx solid #ef444430;
background: #fef0f0;
border: 1rpx solid rgba(239, 68, 68, 0.2);
background: rgba(254, 240, 240, 0.8);
transition: opacity 0.2s;
&:active {
opacity: 0.75;
opacity: 0.65;
}
}
@@ -526,6 +715,12 @@ onMounted(() => bookingStore.fetchMyBookings())
font-weight: 500;
}
.pending-hint {
font-size: 24rpx;
color: #f59e0b;
font-weight: 500;
}
/* ── Spacer ──────────────────────────────────────────── */
.scroll-bottom-spacer {
height: 48rpx;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

View File

@@ -2,8 +2,6 @@ import { defineStore } from 'pinia'
import { ref } from 'vue'
import { get, post, put, del } from '../utils/request'
import type {
WeekTemplate,
WeekTemplateInput,
CardType,
CreateCardTypeDto,
UpdateCardTypeDto,
@@ -13,8 +11,42 @@ import type {
TimeSlot,
CreateManualSlotDto,
PaginatedData,
ScheduleSlotPreview,
PublishDaySlotsDto,
FlashSaleAdminItem,
CreateFlashSaleDto,
UpdateFlashSaleDto,
CreateStudioUploadCredentialDto,
StudioUploadCredential,
} from '@mp-pilates/shared'
interface LegacyPaginatedData<T> {
readonly data: readonly T[]
readonly total: number
readonly page: number
readonly limit: number
}
function normalizePaginatedData<T>(
result: PaginatedData<T> | LegacyPaginatedData<T>,
): PaginatedData<T> {
if ('items' in result) {
return {
items: [...result.items],
total: result.total,
page: result.page,
limit: result.limit,
}
}
return {
items: [...result.data],
total: result.total,
page: result.page,
limit: result.limit,
}
}
export interface AdminStats {
todayBookings: number
totalOrders: number
@@ -23,6 +55,7 @@ export interface AdminStats {
export interface MemberSummary {
userId: string
openid: string
nickname: string
phone: string | null
avatarUrl: string | null
@@ -31,22 +64,26 @@ export interface MemberSummary {
cancelledBookings: number
}
export interface UserMembership {
userId: string
membership: {
id: string
cardTypeId: string
remainingTimes: number | null
startDate: string
expireDate: string
status: string
cardType: {
id: string
name: string
type: string
totalTimes: number | null
durationDays: number
}
} | null
}
export const useAdminStore = defineStore('admin', () => {
// ── Week templates ───────────────────────────────────────────────
const weekTemplates = ref<WeekTemplate[]>([])
async function fetchWeekTemplates(): Promise<WeekTemplate[]> {
const data = await get<WeekTemplate[]>('/admin/week-template')
weekTemplates.value = data
return data
}
async function saveWeekTemplates(templates: WeekTemplateInput[]): Promise<WeekTemplate[]> {
const data = await put<WeekTemplate[]>('/admin/week-template', templates)
weekTemplates.value = data
return data
}
// ── Card types ───────────────────────────────────────────────────
const cardTypes = ref<CardType[]>([])
@@ -57,20 +94,21 @@ export const useAdminStore = defineStore('admin', () => {
}
async function createCardType(dto: CreateCardTypeDto): Promise<CardType> {
const data = await post<CardType>('/admin/card-types', dto)
const data = await post<CardType>('/admin/card-types', dto as unknown as Record<string, unknown>)
await fetchCardTypes()
return data
}
async function updateCardType(id: string, dto: UpdateCardTypeDto): Promise<CardType> {
const data = await put<CardType>(`/admin/card-types/${id}`, dto)
const data = await put<CardType>(`/admin/card-types/${id}`, dto as unknown as Record<string, unknown>)
await fetchCardTypes()
return data
}
async function deleteCardType(id: string): Promise<void> {
await del(`/admin/card-types/${id}`)
async function deleteCardType(id: string): Promise<{ deleted: boolean; deactivated: boolean }> {
const result = await del<{ deleted: boolean; deactivated: boolean }>(`/admin/card-types/${id}`)
await fetchCardTypes()
return result
}
// ── Studio config ────────────────────────────────────────────────
@@ -83,18 +121,31 @@ export const useAdminStore = defineStore('admin', () => {
}
async function saveStudioConfig(dto: UpdateStudioConfigDto): Promise<StudioConfig> {
const data = await put<StudioConfig>('/admin/studio/info', dto)
const data = await put<StudioConfig>('/admin/studio/info', dto as unknown as Record<string, unknown>)
studioConfig.value = data
return data
}
async function createStudioUploadCredential(
dto: CreateStudioUploadCredentialDto,
): Promise<StudioUploadCredential> {
return post<StudioUploadCredential>(
'/admin/studio/upload-credentials',
dto as unknown as Record<string, unknown>,
)
}
// ── Orders ───────────────────────────────────────────────────────
async function fetchAdminOrders(params: {
page?: number
limit?: number
status?: string
}): Promise<PaginatedData<OrderWithDetails>> {
return get<PaginatedData<OrderWithDetails>>('/admin/orders', params)
const result = await get<PaginatedData<OrderWithDetails> | LegacyPaginatedData<OrderWithDetails>>(
'/admin/orders',
params,
)
return normalizePaginatedData(result)
}
// ── Bookings ─────────────────────────────────────────────────────
@@ -111,8 +162,34 @@ export const useAdminStore = defineStore('admin', () => {
page?: number
limit?: number
search?: string
cardType?: string
}): Promise<PaginatedData<MemberSummary>> {
return get<PaginatedData<MemberSummary>>('/admin/members', params)
const cleanParams: Record<string, unknown> = {}
if (params?.page != null) cleanParams.page = params.page
if (params?.limit != null) cleanParams.limit = params.limit
if (params?.search) cleanParams.search = params.search
if (params?.cardType) cleanParams.cardType = params.cardType
return get<PaginatedData<MemberSummary>>('/admin/members', cleanParams)
}
async function getUserMembership(userId: string): Promise<UserMembership> {
return get<UserMembership>(`/admin/members/${userId}/membership`)
}
async function updateUserMembership(
userId: string,
dto: {
cardTypeId: string
remainingTimes?: number | null
startDate: string
expireDate: string
},
): Promise<any> {
return put<any>(`/admin/members/${userId}/membership`, dto)
}
async function deleteUserMembership(userId: string): Promise<void> {
return del<void>(`/admin/members/${userId}/membership`)
}
// ── Time slots ───────────────────────────────────────────────────
@@ -121,7 +198,7 @@ export const useAdminStore = defineStore('admin', () => {
}
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> {
@@ -132,19 +209,57 @@ export const useAdminStore = defineStore('admin', () => {
return post<{ count: number }>('/admin/generate-slots', { startDate, endDate })
}
// ── Schedule management ─────────────────────────────────────────
const schedulePreview = ref<ScheduleSlotPreview[]>([])
const scheduleLoading = ref(false)
async function fetchSchedulePreview(date: string): Promise<ScheduleSlotPreview[]> {
scheduleLoading.value = true
try {
const data = await get<ScheduleSlotPreview[]>('/admin/schedule/preview', { date })
schedulePreview.value = data
return data
} finally {
scheduleLoading.value = false
}
}
async function publishDaySlots(dto: PublishDaySlotsDto): Promise<void> {
await post('/admin/schedule/publish', dto as unknown as Record<string, unknown>)
await fetchSchedulePreview(dto.date)
}
// ── Dashboard stats ──────────────────────────────────────────────
async function fetchDashboardStats(): Promise<AdminStats> {
return get<AdminStats>('/admin/stats')
}
// ── Flash sales ─────────────────────────────────────────────────
async function fetchFlashSales(params?: {
page?: number
limit?: number
}): Promise<PaginatedData<FlashSaleAdminItem>> {
return get<PaginatedData<FlashSaleAdminItem>>('/admin/flash-sales', params as Record<string, unknown>)
}
async function createFlashSale(dto: CreateFlashSaleDto): Promise<FlashSaleAdminItem> {
return post<FlashSaleAdminItem>('/admin/flash-sales', dto as unknown as Record<string, unknown>)
}
async function updateFlashSale(id: string, dto: UpdateFlashSaleDto): Promise<FlashSaleAdminItem> {
return put<FlashSaleAdminItem>(`/admin/flash-sales/${id}`, dto as unknown as Record<string, unknown>)
}
async function deleteFlashSale(id: string): Promise<{ deleted: boolean }> {
return del<{ deleted: boolean }>(`/admin/flash-sales/${id}`)
}
return {
// State
weekTemplates,
cardTypes,
studioConfig,
// Week templates
fetchWeekTemplates,
saveWeekTemplates,
schedulePreview,
scheduleLoading,
// Card types
fetchCardTypes,
createCardType,
@@ -153,18 +268,30 @@ export const useAdminStore = defineStore('admin', () => {
// Studio
fetchStudioConfig,
saveStudioConfig,
createStudioUploadCredential,
// Orders
fetchAdminOrders,
// Bookings
fetchAdminBookings,
// Members
fetchMembers,
getUserMembership,
updateUserMembership,
deleteUserMembership,
// Time slots
fetchSlotsByDate,
createManualSlot,
closeSlot,
generateSlots,
// Schedule
fetchSchedulePreview,
publishDaySlots,
// Stats
fetchDashboardStats,
// Flash sales
fetchFlashSales,
createFlashSale,
updateFlashSale,
deleteFlashSale,
}
})

View File

@@ -3,16 +3,29 @@ import { ref } from 'vue'
import type {
TimeSlotWithBookingStatus,
BookingWithDetails,
BookingWithUser,
BookingStatusHistory,
CreateBookingDto,
TeachingScheduleSlot,
} from '@mp-pilates/shared'
import { get, post, put } from '../utils/request'
/** Server paginated responses use `data` field, not `items` from the shared type */
interface ServerPaginatedResult<T> {
readonly data: readonly T[]
readonly total: number
readonly page: number
readonly limit: number
}
export const useBookingStore = defineStore('booking', () => {
const slots = ref<readonly TimeSlotWithBookingStatus[]>([])
const myBookings = ref<readonly BookingWithDetails[]>([])
const upcomingBookings = ref<readonly BookingWithDetails[]>([])
const teachingSchedule = ref<readonly TeachingScheduleSlot[]>([])
const loadingSlots = ref(false)
const loadingBookings = ref(false)
const loadingTeachingSchedule = ref(false)
async function fetchSlots(date: string) {
loadingSlots.value = true
@@ -39,10 +52,12 @@ export const useBookingStore = defineStore('booking', () => {
async function fetchMyBookings(status?: string) {
loadingBookings.value = true
try {
const params = status ? { status } : {}
myBookings.value = await get<BookingWithDetails[]>('/booking/my', params)
const params: Record<string, unknown> = status ? { status } : {}
const paginated = await get<ServerPaginatedResult<BookingWithDetails>>('/booking/my', params)
myBookings.value = Array.isArray(paginated.data) ? paginated.data : []
} catch (err) {
console.error('Fetch bookings failed:', err)
myBookings.value = []
} finally {
loadingBookings.value = false
}
@@ -50,22 +65,99 @@ export const useBookingStore = defineStore('booking', () => {
async function fetchUpcomingBookings() {
try {
upcomingBookings.value = await get<BookingWithDetails[]>('/booking/my/upcoming')
const result = await get<BookingWithDetails[]>('/booking/my/upcoming')
upcomingBookings.value = Array.isArray(result) ? result : []
} catch (err) {
console.error('Fetch upcoming bookings failed:', err)
upcomingBookings.value = []
}
}
async function fetchTeachingSchedule(date: string) {
loadingTeachingSchedule.value = true
try {
const result = await get<TeachingScheduleSlot[]>('/admin/teaching-schedule', { date })
teachingSchedule.value = Array.isArray(result) ? result : []
return teachingSchedule.value
} catch (err) {
console.error('Fetch teaching schedule failed:', err)
teachingSchedule.value = []
throw err
} finally {
loadingTeachingSchedule.value = false
}
}
// ─── Admin methods ──────────────────────────────────────────────────────
async function fetchAllAdminBookings(
page = 1,
limit = 20,
status?: string,
): Promise<ServerPaginatedResult<BookingWithUser>> {
const params: Record<string, unknown> = { page, limit }
if (status) params.status = status
const paginated = await get<ServerPaginatedResult<BookingWithUser>>('/admin/bookings', params)
return paginated
}
async function confirmBooking(bookingId: string, remark?: string) {
const result = await put<BookingWithDetails>(`/booking/${bookingId}/confirm`, {
remark,
})
return result
}
async function completeBooking(bookingId: string, remark?: string) {
const result = await put<BookingWithDetails>(`/booking/${bookingId}/complete`, {
remark,
})
return result
}
async function markNoShow(bookingId: string, remark?: string) {
const result = await put<BookingWithDetails>(`/booking/${bookingId}/noshow`, {
remark,
})
return result
}
async function fetchBookingHistory(bookingId: string): Promise<BookingStatusHistory[]> {
const result = await get<BookingStatusHistory[]>(`/booking/${bookingId}/history`)
return result
}
async function fetchBookingById(bookingId: string) {
const result = await get<BookingWithDetails | BookingWithUser>(`/booking/${bookingId}`)
return result
}
async function fetchSlotById(slotId: string) {
const result = await get<TimeSlotWithBookingStatus>(`/time-slot/${slotId}`)
return result
}
return {
slots,
myBookings,
upcomingBookings,
teachingSchedule,
loadingSlots,
loadingBookings,
loadingTeachingSchedule,
fetchSlots,
createBooking,
cancelBooking,
fetchMyBookings,
fetchUpcomingBookings,
fetchTeachingSchedule,
fetchAllAdminBookings,
confirmBooking,
completeBooking,
markNoShow,
fetchBookingHistory,
fetchSlotById,
fetchBookingById,
}
})

View File

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

View File

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

View File

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

View File

@@ -1,16 +1,26 @@
/* uni.scss - 全局样式变量 */
$brand-color: #1a1a2e;
$brand-light: #e2d1c3;
$accent-color: #c9a87c;
$text-primary: #333333;
$text-secondary: #666666;
$text-hint: #999999;
$bg-page: #f5f5f5;
/* ── 主题色系 ───────────────────────────────────────────── */
$primary-color: #a9bfcc;
$primary-dark: #7ba5be;
$primary-light: #c8d8e4;
$primary-bg: #f0f6f9;
$primary-border: #d8eaf4;
$primary-selected-bg: #EFF6F9;
/* ── 通用 ─────────────────────────────────────────────── */
$brand-color: #4A4035;
$brand-light: #c8d8e4;
$accent-color: #7ba5be;
$text-primary: #4A4035;
$text-secondary: #7A6A5A;
$text-hint: #A09080;
$bg-page: #FAF8F5;
$bg-card: #ffffff;
$border-color: #eeeeee;
$success-color: #52c41a;
$warning-color: #faad14;
$error-color: #ff4d4f;
$border-color: rgba(180, 160, 130, 0.2);
$success-color: #7A9E7E;
$warning-color: #e8a87c;
$error-color: #C47A7A;
$radius-sm: 8rpx;
$radius-md: 16rpx;
$radius-lg: 24rpx;
@@ -19,3 +29,9 @@ $spacing-sm: 16rpx;
$spacing-md: 24rpx;
$spacing-lg: 32rpx;
$spacing-xl: 48rpx;
/* ── Shimmer animation ──────────────────────────────────── */
@keyframes shimmer {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}

View File

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

View File

@@ -0,0 +1,81 @@
import { BookingStatus } from '@mp-pilates/shared'
/** 格式化日期显示:今天/明天/M月D日 星期X */
export function formatDateDisplay(dateStr: string): string {
const normalized = dateStr.slice(0, 10)
const [y, m, d] = normalized.split('-').map(Number)
const localDate = new Date(y, m - 1, d)
const today = new Date()
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
const tomorrow = new Date(today.getTime() + 86400000)
const tomorrowStr = `${tomorrow.getFullYear()}-${String(tomorrow.getMonth() + 1).padStart(2, '0')}-${String(tomorrow.getDate()).padStart(2, '0')}`
if (normalized === todayStr) return `今天 ${m}${d}`
if (normalized === tomorrowStr) return `明天 ${m}${d}`
const weekdayLabels = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
return `${m}${d}${weekdayLabels[localDate.getDay()]}`
}
// ─── Booking status helpers ───────────────────────────────────────────────────
export const BOOKING_STATUS_LABELS: Record<string, string> = {
[BookingStatus.PENDING_CONFIRMATION]: '待确认',
[BookingStatus.CONFIRMED]: '已确认',
[BookingStatus.CANCELLED]: '已取消',
[BookingStatus.COMPLETED]: '已完成',
[BookingStatus.NO_SHOW]: '未出席',
}
export const BOOKING_STATUS_BADGE_CLASSES: Record<string, string> = {
[BookingStatus.PENDING_CONFIRMATION]: 'badge--pending',
[BookingStatus.CONFIRMED]: 'badge--confirmed',
[BookingStatus.CANCELLED]: 'badge--cancelled',
[BookingStatus.COMPLETED]: 'badge--completed',
[BookingStatus.NO_SHOW]: 'badge--noshow',
}
export const BOOKING_STATUS_STRIPE_CLASSES: Record<string, string> = {
[BookingStatus.PENDING_CONFIRMATION]: 'stripe--pending',
[BookingStatus.CONFIRMED]: 'stripe--confirmed',
[BookingStatus.CANCELLED]: 'stripe--cancelled',
[BookingStatus.COMPLETED]: 'stripe--completed',
[BookingStatus.NO_SHOW]: 'stripe--noshow',
}
export const BOOKING_STATUS_BANNER_CLASSES: Record<string, string> = {
[BookingStatus.PENDING_CONFIRMATION]: 'banner--pending',
[BookingStatus.CONFIRMED]: 'banner--confirmed',
[BookingStatus.CANCELLED]: 'banner--cancelled',
[BookingStatus.COMPLETED]: 'banner--completed',
[BookingStatus.NO_SHOW]: 'banner--noshow',
}
export function bookingStatusLabel(status: string): string {
return BOOKING_STATUS_LABELS[status] ?? status
}
export function bookingStatusBadgeClass(status: string): string {
return BOOKING_STATUS_BADGE_CLASSES[status] ?? ''
}
export function bookingStatusStripeClass(status: string): string {
return BOOKING_STATUS_STRIPE_CLASSES[status] ?? ''
}
export function bookingStatusBannerClass(status: string): string {
return BOOKING_STATUS_BANNER_CLASSES[status] ?? ''
}
export function bookingTimelineDotClass(status: string): string {
switch (status) {
case BookingStatus.PENDING_CONFIRMATION: return 'dot--pending'
case BookingStatus.CONFIRMED: return 'dot--confirmed'
case BookingStatus.COMPLETED: return 'dot--completed'
case BookingStatus.CANCELLED: return 'dot--cancelled'
case BookingStatus.NO_SHOW: return 'dot--noshow'
default: return ''
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,54 @@
/**
* System info utilities — replaces deprecated uni.getSystemInfoSync()
* with the recommended granular APIs.
*/
interface SystemLayout {
/** Status bar height in px */
readonly statusBarHeight: number
/** Window width in px */
readonly windowWidth: number
/** Custom nav bar height in px (status bar + title bar) */
readonly navBarHeight: number
}
let cached: SystemLayout | null = null
/**
* Returns layout dimensions using the new granular APIs.
* Falls back to getSystemInfoSync only if the new APIs are unavailable.
* Results are cached since these values never change during a session.
*/
export function getSystemLayout(): SystemLayout {
if (cached) return cached
let statusBarHeight = 20
let windowWidth = 375
try {
// New recommended APIs (WeChat base lib >= 2.25.3)
const windowInfo = uni.getWindowInfo()
const deviceInfo = uni.getDeviceInfo()
statusBarHeight = windowInfo.statusBarHeight ?? 20
windowWidth = windowInfo.windowWidth ?? 375
// Silence unused var — deviceInfo is here for future use
void deviceInfo
} catch {
// Fallback for older base lib versions
try {
const sysInfo = uni.getSystemInfoSync()
statusBarHeight = sysInfo.statusBarHeight ?? 20
windowWidth = sysInfo.windowWidth ?? 375
} catch {
// Use defaults
}
}
const navTitlePx = 88 * (windowWidth / 750)
const navBarHeight = Math.round(statusBarHeight + navTitlePx)
cached = { statusBarHeight, windowWidth, navBarHeight }
return cached
}

View File

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

View File

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

View File

@@ -9,4 +9,11 @@ export default defineConfig({
'@': resolve(__dirname, 'src'),
},
},
css: {
preprocessorOptions: {
scss: {
api: 'modern',
},
},
},
})

35
packages/server/.env Normal file
View File

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

View File

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

Binary file not shown.

View File

@@ -0,0 +1,25 @@
-----BEGIN CERTIFICATE-----
MIIETDCCAzSgAwIBAgIUepDZan7RoSnpjbX9XzqE7cNLKsYwDQYJKoZIhvcNAQEL
BQAwXjELMAkGA1UEBhMCQ04xEzARBgNVBAoTClRlbnBheS5jb20xHTAbBgNVBAsT
FFRlbnBheS5jb20gQ0EgQ2VudGVyMRswGQYDVQQDExJUZW5wYXkuY29tIFJvb3Qg
Q0EwHhcNMjYwNDA1MDYwMjA1WhcNMzEwNDA0MDYwMjA1WjCBpTETMBEGA1UEAwwK
MTExMDUzMDAyMzEbMBkGA1UECgwS5b6u5L+h5ZWG5oi357O757ufMVEwTwYDVQQL
DEjmt7HlnLPluILlrp3lronljLropb/kuaHooZfpgZPogZrnhKblgaXouqvlt6Xk
vZzlrqTvvIjkuKrkvZPlt6XllYbmiLfvvIkxCzAJBgNVBAYTAkNOMREwDwYDVQQH
DAhTaGVuWmhlbjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAPAJ7FVi
shMDXjsI4bjWxRq1FT3J3K1tSernV3Ql/ZYaEs/dSay5a3ITuipcDsLnmMPrP8qf
CIfBT5h6HikfZ2xSiGcnRm5LNZsurSevpTgkSFf14ez3Eh3kMd/moRBwMZBZwftC
cx+HokiyqCGmR8OQRIurC/ZY7mSrBlSVDg4ohM7a0QPyJazEpxs1IKg58UadSP6D
gLqh/zDPn1+GBXIenCxYf2Sni5uommXdDh1/L8bga3DeZDcb1s57PX4cPGV131MO
uJfug/hzdHX7FuihXPobtUqb9e+IN4SDNJ/fgG+lcumg6G68dCcE3nZovtwFlqiB
EHs1gwUPRb7Cgo8CAwEAAaOBuTCBtjAJBgNVHRMEAjAAMAsGA1UdDwQEAwID+DCB
mwYDVR0fBIGTMIGQMIGNoIGKoIGHhoGEaHR0cDovL2V2Y2EuaXRydXMuY29tLmNu
L3B1YmxpYy9pdHJ1c2NybD9DQT0xQkQ0MjIwRTUwREJDMDRCMDZBRDM5NzU0OTg0
NkMwMUMzRThFQkQyJnNnPUhBQ0M0NzFCNjU0MjJFMTJCMjdBOUQzM0E4N0FEMUNE
RjU5MjZFMTQwMzcxMA0GCSqGSIb3DQEBCwUAA4IBAQApAXaaagCrm9kkUf6Po2AL
Hm2oE5a99PgQS6O1R3i9pxsDVOxo/Ftt6NzjE58y48yBU/g/hp6HIQyz9FyzFuz7
0QTOcmXHePfwNpLl6IPntxyk7XhKYx9Ebj4ZGSbby7L1E+9h/OwlnAJ60W1023CE
qGQWLZD7WgmceD5a6YUYaamwJ2q3sICIozzTkeaT/mn1Z89ML4ns6KWXo9q62FPo
TP5Fm9aJyu/50xLQKANDYu0qL0PcL/4HCU1/OrR9xYt7IsYT4Sa4f0y5HU4vkbVs
Q+MfBVusvvutRHIPXfzFa0+1wLDuCr990FLlNcsLSVvMaQx5DQJhiUFJCQbwbwf+
-----END CERTIFICATE-----

View File

@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDwCexVYrITA147
COG41sUatRU9ydytbUnq51d0Jf2WGhLP3UmsuWtyE7oqXA7C55jD6z/KnwiHwU+Y
eh4pH2dsUohnJ0ZuSzWbLq0nr6U4JEhX9eHs9xId5DHf5qEQcDGQWcH7QnMfh6JI
sqghpkfDkESLqwv2WO5kqwZUlQ4OKITO2tED8iWsxKcbNSCoOfFGnUj+g4C6of8w
z59fhgVyHpwsWH9kp4ubqJpl3Q4dfy/G4Gtw3mQ3G9bOez1+HDxldd9TDriX7oP4
c3R1+xbooVz6G7VKm/XviDeEgzSf34BvpXLpoOhuvHQnBN52aL7cBZaogRB7NYMF
D0W+woKPAgMBAAECggEBAKKpZtDZ5+iAgMuqkiPKzpjxm2par8OKauvXR2k7EWQ1
WQgpYfK9V/VfLunjplEn1lr1wS3SpVoxgnnGT0f4swIxz6NvdwfoyXPWpppdKa4o
0CljQ21sZIeDCtU6mWzlSoESgiR9fDwikrOG9e6PmtQIoJqxF5Mh4rKvPsP0mii2
tnoCy8vltaSLcchWnkCRe3jWn+OZfI8qOE8gYw3jFbFMcKPXf47S88TkiV/Fi/VA
Vbn8S2my74OollqOZpy3ss4SuBzxmsT7CEL1obW3wPPbMlqyaJX7nGlCrOXd9c+s
9zx0X7n2iPpFhi39kHPZOyoYjBJ7Xpg9N3rHRjIMj7kCgYEA/HfyEU1JHUBk/zGA
cwSxW5OewixIlXCQ5eIQixaK+z3xG54Z31n8Tb+KMhH0FkMGFmzuv0IQbEJPERnc
qKLrc9oDZzEwXpypnrGgxsxEALxRnS1aHGH0gKs8FyjLLmcX49cZdqisTaeEjthz
FKos52fYyQGDbk5enF4VdRY5V3UCgYEA82V349iddfSwLovI/Qeq2QZaDeswBI/r
mV80kSIfVx71XReBFe6a7NZS6Fck76bkXiKliPCQo/vU8LZif7HUY7pO5X7JGZuY
ApyFoN02CtNKwBU4mbUx24hbPVUdHYdz5BaqwR2OIGWLZTP8X8Qkd5dLA2Sfln+1
auXQdjyxNXMCgYEAy9s2NM5I+Tuj0YNxCm6Bn0ZFbNhBC5nHBjhRz102f8P2SayR
i42nckf1GJTymH8qDTWMWhbIGAI6wb42NFzI7dTd5pcLTXoGZENdZOhPCKEG7XlP
R5e4y6R4cuLXnPJVkf1/bBaqelGHcahI1CjM9VUe8L8uFwVk07IMdWyqhHkCgYAq
ntYDm+bWxOYlAG1NgY41OpuCXHCoG9uRm85Eq8j5JH6qsnb0NDgEyPLzpG7fWEYd
Bcwe0qFBVdPP4uAUpDsgy3sNTMpCJbDUpDvyE0pnUuCACjdDEyuL2bDAaKsUhKeS
hTWZY2eD3MQwEI5c5qfMGT4VdgVMAUjvUxbR3YbaaQKBgQC7hDlqYZ8kCd6Im/q0
N8R9fEz/8ITlzWb9hAMEMAX/s54u0V0/kvIY6qgc9mZis9hJMhJpaK8G4hGrEbI3
kxHLOZd3enJw/BsbU/K2XA2pjv981GFlzGCSawgkmcY0pZ3U1DjwtAwC0HW/3c9E
f4hvelBU/Qi3HzrYkCcp8Ms54w==
-----END PRIVATE KEY-----

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,37 @@
import { Controller, Get, UseGuards } from '@nestjs/common'
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
import { Roles } from '../auth/roles.decorator'
import { RolesGuard } from '../auth/roles.guard'
import { UserRole } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
interface AdminStats {
todayBookings: number
totalOrders: number
totalBookings: number
}
@Controller('admin')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
export class AdminController {
constructor(private readonly prisma: PrismaService) {}
@Get('stats')
async getStats(): Promise<AdminStats> {
const today = new Date()
today.setUTCHours(0, 0, 0, 0)
const [todayBookings, totalOrders, totalBookings] = await Promise.all([
this.prisma.booking.count({
where: {
timeSlot: { date: today },
},
}),
this.prisma.order.count(),
this.prisma.booking.count(),
])
return { todayBookings, totalOrders, totalBookings }
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common'
import { AdminController } from './admin.controller'
@Module({
controllers: [AdminController],
})
export class AdminModule {}

View File

@@ -10,6 +10,9 @@ import { MembershipModule } from './membership/membership.module'
import { BookingModule } from './booking/booking.module'
import { SchedulerModule } from './scheduler/scheduler.module'
import { PaymentModule } from './payment/payment.module'
import { AdminModule } from './admin/admin.module'
import { FlashSaleModule } from './flash-sale/flash-sale.module'
import { InviteModule } from './invite/invite.module'
@Module({
imports: [
@@ -26,6 +29,9 @@ import { PaymentModule } from './payment/payment.module'
BookingModule,
SchedulerModule,
PaymentModule,
AdminModule,
FlashSaleModule,
InviteModule,
],
controllers: [AppController],
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import {
BadRequestException,
Body,
Controller,
Get,
@@ -62,6 +63,18 @@ export class BookingController {
)
}
@Get('booking/:id/history')
@UseGuards(JwtAuthGuard)
async getBookingStatusHistory(@Param('id') id: string) {
return this.bookingService.getBookingStatusHistory(id)
}
@Get('booking/:id')
@UseGuards(JwtAuthGuard)
async getBookingById(@Param('id') id: string) {
return this.bookingService.getBookingById(id)
}
// ─── Admin Endpoints ──────────────────────────────────────────────────────
@Get('admin/bookings')
@@ -78,4 +91,47 @@ export class BookingController {
status,
)
}
@Get('admin/teaching-schedule')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
async getTeachingSchedule(@Query('date') date?: string) {
if (!date) {
throw new BadRequestException('date is required')
}
return this.bookingService.getTeachingScheduleByDate(date)
}
@Put('booking/:id/confirm')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
async confirmBooking(
@CurrentUser('sub') operatorId: string,
@Param('id') id: string,
@Body() body: { remark?: string },
) {
return this.bookingService.confirmBooking(id, operatorId, body.remark)
}
@Put('booking/:id/complete')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
async completeBooking(
@CurrentUser('sub') operatorId: string,
@Param('id') id: string,
@Body() body: { remark?: string },
) {
return this.bookingService.completeBooking(id, operatorId, body.remark)
}
@Put('booking/:id/noshow')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
async markNoShow(
@CurrentUser('sub') operatorId: string,
@Param('id') id: string,
@Body() body: { remark?: string },
) {
return this.bookingService.markNoShow(id, operatorId, body.remark)
}
}

View File

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

View File

@@ -5,12 +5,20 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common'
import { Booking, Membership, TimeSlot } from '@prisma/client'
import { BookingStatus, CardTypeCategory, MembershipStatus, TimeSlotStatus } from '@mp-pilates/shared'
import { Booking, Membership, TimeSlot, BookingStatusHistory } from '@prisma/client'
import {
BookingStatus,
CardTypeCategory,
MembershipStatus,
TimeSlotStatus,
type TeachingScheduleSlot,
} from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
import { MembershipService } from '../membership/membership.service'
import { StudioService } from '../studio/studio.service'
import { SubscriptionMessageService } from '../user/subscription-message.service'
import { CreateBookingDto } from './dto/create-booking.dto'
import { InviteService } from '../invite/invite.service'
// ─── Types ─────────────────────────────────────────────────────────────────
@@ -31,10 +39,9 @@ export interface CancelBookingResult {
refunded: boolean
}
// ─── Helpers ───────────────────────────────────────────────────────────────
// ─── Helpers ───────────────────────────────────────────────────────────────
function buildSlotStartMs(slotDate: Date, startTime: string): number {
// slotDate is stored as DATE (midnight UTC); startTime is "HH:mm"
const [hours, minutes] = startTime.split(':').map(Number)
const d = new Date(slotDate)
d.setUTCHours(hours, minutes, 0, 0)
@@ -49,6 +56,8 @@ export class BookingService {
private readonly prisma: PrismaService,
private readonly membershipService: MembershipService,
private readonly studioService: StudioService,
private readonly subscriptionMessageService: SubscriptionMessageService,
private readonly inviteService: InviteService,
) {}
// ─── Create Booking ──────────────────────────────────────────────────────
@@ -71,11 +80,20 @@ export class BookingService {
)
}
// 2. Check for duplicate booking (@@unique [userId, timeSlotId])
// 2. Find existing booking record for this user + slot.
// The DB keeps a unique key on this pair, so cancelled bookings must be revived instead of recreated.
const existing = await tx.booking.findUnique({
where: { userId_timeSlotId: { userId, timeSlotId: dto.timeSlotId } },
where: {
userId_timeSlotId: {
userId,
timeSlotId: dto.timeSlotId,
},
},
})
if (existing) {
if (
existing &&
(existing.status === 'PENDING_CONFIRMATION' || existing.status === 'CONFIRMED')
) {
throw new ConflictException('You have already booked this time slot')
}
@@ -102,10 +120,7 @@ export class BookingService {
cardType.type === CardTypeCategory.TRIAL
if (isTimeBased) {
// 4a. TIMES / TRIAL: must have remaining times
if ((membership.remainingTimes ?? 0) <= 0) {
throw new BadRequestException('No remaining times on this membership')
}
// 4a. TIMES / TRIAL: must have remaining times (check at confirm time, not booking time)
} else {
// 4b. DURATION: must not be expired
if (membership.expireDate <= new Date()) {
@@ -113,38 +128,125 @@ export class BookingService {
}
}
// 5. Create booking
const newBooking = await tx.booking.create({
// 5. Create booking or revive a previously cancelled booking.
const newBooking = existing?.status === BookingStatus.CANCELLED
? await tx.booking.update({
where: { id: existing.id },
data: {
membershipId: dto.membershipId,
status: BookingStatus.PENDING_CONFIRMATION,
cancelledAt: null,
confirmedAt: null,
completedAt: null,
operatorId: null,
},
})
: await tx.booking.create({
data: {
userId,
timeSlotId: dto.timeSlotId,
membershipId: dto.membershipId,
status: BookingStatus.PENDING_CONFIRMATION,
},
})
// 6. Record status history: created or re-created from cancelled state.
await tx.bookingStatusHistory.create({
data: {
userId,
timeSlotId: dto.timeSlotId,
membershipId: dto.membershipId,
status: BookingStatus.CONFIRMED,
bookingId: newBooking.id,
fromStatus: existing?.status === BookingStatus.CANCELLED
? BookingStatus.CANCELLED
: null,
toStatus: BookingStatus.PENDING_CONFIRMATION,
operatorId: userId,
remark: existing?.status === BookingStatus.CANCELLED
? '学员重新发起预约'
: '学员发起预约',
},
})
// 6. Increment bookedCount; set FULL if at capacity
const newBookedCount = timeSlot.bookedCount + 1
return newBooking
})
// Re-fetch with relations after transaction
const bookingWithRelations = await this.fetchBookingWithRelations(booking.id)
await this.trySendAdminBookingCreatedSubscriptionMessages(bookingWithRelations)
return bookingWithRelations
}
// ─── Confirm Booking (Admin) ─────────────────────────────────────────────
async confirmBooking(
bookingId: string,
operatorId: string,
remark?: string,
): Promise<BookingWithRelations> {
const booking = await this.prisma.$transaction(async (tx) => {
// 1. Find booking with timeSlot and membership
const existing = await tx.booking.findUnique({
where: { id: bookingId },
include: {
timeSlot: true,
membership: { include: { cardType: true } },
},
})
if (!existing) {
throw new NotFoundException(`Booking ${bookingId} not found`)
}
if (existing.status !== BookingStatus.PENDING_CONFIRMATION) {
throw new BadRequestException(
`Cannot confirm booking with status: ${existing.status}`,
)
}
// 2. Validate membership still has available times
const cardType = existing.membership.cardType
const isTimeBased =
cardType.type === CardTypeCategory.TIMES ||
cardType.type === CardTypeCategory.TRIAL
if (isTimeBased) {
if ((existing.membership.remainingTimes ?? 0) <= 0) {
throw new BadRequestException('No remaining times on this membership')
}
} else {
if (existing.membership.expireDate <= new Date()) {
throw new BadRequestException('Membership has expired')
}
}
// 3. Update booking status to CONFIRMED
const updated = await tx.booking.update({
where: { id: bookingId },
data: {
status: BookingStatus.CONFIRMED,
confirmedAt: new Date(),
operatorId,
},
})
// 4. Increment bookedCount; set FULL if at capacity
const newBookedCount = existing.timeSlot.bookedCount + 1
const newSlotStatus =
newBookedCount >= timeSlot.capacity ? TimeSlotStatus.FULL : TimeSlotStatus.OPEN
newBookedCount >= existing.timeSlot.capacity ? TimeSlotStatus.FULL : TimeSlotStatus.OPEN
await tx.timeSlot.update({
where: { id: dto.timeSlotId },
where: { id: existing.timeSlotId },
data: {
bookedCount: newBookedCount,
status: newSlotStatus,
},
})
// 7. Deduct membership (inside transaction — replicate logic to avoid
// calling the service method which uses the outer prisma client)
// 5. Deduct membership times
if (isTimeBased) {
const newRemainingTimes = (membership.remainingTimes ?? 0) - 1
const newRemainingTimes = (existing.membership.remainingTimes ?? 0) - 1
const newMembershipStatus =
newRemainingTimes <= 0 ? MembershipStatus.USED_UP : MembershipStatus.ACTIVE
await tx.membership.update({
where: { id: dto.membershipId },
where: { id: existing.membershipId },
data: {
remainingTimes: newRemainingTimes,
status: newMembershipStatus,
@@ -152,11 +254,95 @@ export class BookingService {
})
}
return newBooking
// 6. Record status history
await tx.bookingStatusHistory.create({
data: {
bookingId,
fromStatus: BookingStatus.PENDING_CONFIRMATION,
toStatus: BookingStatus.CONFIRMED,
operatorId,
remark: remark || '老师确认预约',
},
})
return updated
})
// Re-fetch with relations after transaction
return this.fetchBookingWithRelations(booking.id)
const confirmedBooking = await this.fetchBookingWithRelations(booking.id)
await this.trySendBookingConfirmedSubscriptionMessage(confirmedBooking)
return confirmedBooking
}
// ─── Complete / NoShow Booking (Admin) ──────────────────────────────────
async completeBooking(
bookingId: string,
operatorId: string,
remark?: string,
): Promise<BookingWithRelations> {
return this.markBookingStatus(bookingId, operatorId, BookingStatus.COMPLETED, remark || '老师核销完成')
}
async markNoShow(
bookingId: string,
operatorId: string,
remark?: string,
): Promise<BookingWithRelations> {
return this.markBookingStatus(bookingId, operatorId, BookingStatus.NO_SHOW, remark || '学员未出席')
}
private async markBookingStatus(
bookingId: string,
operatorId: string,
toStatus: BookingStatus,
remark: string,
): Promise<BookingWithRelations> {
const booking = await this.prisma.$transaction(async (tx) => {
const existing = await tx.booking.findUnique({
where: { id: bookingId },
include: { timeSlot: true },
})
if (!existing) {
throw new NotFoundException(`Booking ${bookingId} not found`)
}
if (existing.status !== BookingStatus.CONFIRMED) {
throw new BadRequestException(
`Cannot mark as ${toStatus} with status: ${existing.status}`,
)
}
const updateData: Record<string, unknown> = {
status: toStatus,
operatorId,
}
if (toStatus === BookingStatus.COMPLETED) {
updateData.completedAt = new Date()
}
const updated = await tx.booking.update({
where: { id: bookingId },
data: updateData,
})
await tx.bookingStatusHistory.create({
data: {
bookingId,
fromStatus: BookingStatus.CONFIRMED,
toStatus,
operatorId,
remark,
},
})
return updated
})
const result = await this.fetchBookingWithRelations(booking.id)
if (toStatus === BookingStatus.COMPLETED) {
await this.inviteService.recordQualifiedTrialBooking(result.id)
}
return result
}
// ─── Cancel Booking ──────────────────────────────────────────────────────
@@ -165,7 +351,6 @@ export class BookingService {
userId: string,
bookingId: string,
): Promise<CancelBookingResult> {
// 1. Find booking with timeSlot and membership
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
include: {
@@ -180,13 +365,37 @@ export class BookingService {
if (booking.userId !== userId) {
throw new ForbiddenException('This booking does not belong to you')
}
let refunded = false
// PENDING_CONFIRMATION: can cancel directly, no refund needed (times never deducted)
if (booking.status === BookingStatus.PENDING_CONFIRMATION) {
await this.prisma.$transaction(async (tx) => {
await tx.booking.update({
where: { id: bookingId },
data: { status: BookingStatus.CANCELLED },
})
await tx.bookingStatusHistory.create({
data: {
bookingId,
fromStatus: BookingStatus.PENDING_CONFIRMATION,
toStatus: BookingStatus.CANCELLED,
operatorId: userId,
remark: '学员取消预约(待确认状态)',
},
})
})
return { booking: { ...booking, status: BookingStatus.CANCELLED }, refunded }
}
// CONFIRMED: check cancel time limit and potentially refund
if (booking.status !== BookingStatus.CONFIRMED) {
throw new BadRequestException(
`Cannot cancel booking with status: ${booking.status}`,
)
}
// 2. Determine refund eligibility
const studioConfig = await this.studioService.getInfo()
const { cancelHoursLimit } = studioConfig
@@ -194,9 +403,7 @@ export class BookingService {
const deadlineMs = Date.now() + cancelHoursLimit * 3600 * 1000
const withinLimit = slotStartMs >= deadlineMs
// 3. Transaction: cancel booking, restore slot, conditionally restore membership
const updatedBooking = await this.prisma.$transaction(async (tx) => {
// Cancel the booking
const cancelled = await tx.booking.update({
where: { id: bookingId },
data: {
@@ -241,13 +448,48 @@ export class BookingService {
status: newStatus,
},
})
refunded = true
}
}
await tx.bookingStatusHistory.create({
data: {
bookingId,
fromStatus: BookingStatus.CONFIRMED,
toStatus: BookingStatus.CANCELLED,
operatorId: userId,
remark: refunded ? '学员取消预约(超时退款)' : '学员取消预约(未超时不退款)',
},
})
return cancelled
})
return { booking: { ...updatedBooking }, refunded: withinLimit }
return { booking: { ...updatedBooking }, refunded }
}
// ─── Get Booking Status History ──────────────────────────────────────────
async getBookingStatusHistory(bookingId: string): Promise<BookingStatusHistory[]> {
const history = await this.prisma.bookingStatusHistory.findMany({
where: { bookingId },
orderBy: { createdAt: 'asc' },
})
return history
}
// ─── Get Booking By Id ─────────────────────────────────────────────────
async getBookingById(bookingId: string): Promise<BookingWithRelations | null> {
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
include: {
timeSlot: true,
membership: { include: { cardType: true } },
user: { select: { id: true, nickname: true, phone: true } },
},
})
return booking as BookingWithRelations | null
}
// ─── Get My Bookings ─────────────────────────────────────────────────────
@@ -294,7 +536,7 @@ export class BookingService {
const bookings = await this.prisma.booking.findMany({
where: {
userId,
status: BookingStatus.CONFIRMED,
status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] },
timeSlot: {
date: { gte: today },
},
@@ -346,7 +588,73 @@ export class BookingService {
}
}
// ─── Private Helpers ──────────────────────────────────────────────────────
async getTeachingScheduleByDate(date: string): Promise<TeachingScheduleSlot[]> {
const dayStart = new Date(`${date}T00:00:00.000Z`)
if (Number.isNaN(dayStart.getTime())) {
throw new BadRequestException('Invalid date')
}
const slots = await this.prisma.timeSlot.findMany({
where: {
date: dayStart,
bookings: {
some: {
status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] },
},
},
},
include: {
bookings: {
where: {
status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] },
},
include: {
user: {
select: {
id: true,
nickname: true,
phone: true,
},
},
},
orderBy: [
{ status: 'asc' },
{ createdAt: 'asc' },
],
},
},
orderBy: [
{ startTime: 'asc' },
{ endTime: 'asc' },
],
})
return slots
.map((slot) => ({
slotId: slot.id,
date,
startTime: slot.startTime,
endTime: slot.endTime,
bookedCount: slot.bookedCount,
capacity: slot.capacity,
students: slot.bookings.map((booking) => ({
bookingId: booking.id,
userId: booking.user.id,
nickname: booking.user.nickname,
phone: booking.user.phone,
status: booking.status as BookingStatus,
})),
}))
.sort((a, b) => {
const byStart = a.startTime.localeCompare(b.startTime)
if (byStart !== 0) {
return byStart
}
return a.endTime.localeCompare(b.endTime)
})
}
// ─── Private Helpers ─────────────────────────────────────────────────────
private async fetchBookingWithRelations(bookingId: string): Promise<BookingWithRelations> {
const booking = await this.prisma.booking.findUnique({
@@ -363,4 +671,90 @@ export class BookingService {
return { ...booking } as BookingWithRelations
}
private async trySendBookingConfirmedSubscriptionMessage(
booking: BookingWithRelations,
): Promise<void> {
try {
const user = await this.prisma.user.findUnique({
where: { id: booking.userId },
select: { openid: true },
})
if (!user?.openid) {
return
}
const studio = await this.studioService.getInfo()
const bookingDate = booking.timeSlot.date
const dateLabel = `${bookingDate.getFullYear()}-${String(bookingDate.getMonth() + 1).padStart(2, '0')}-${String(bookingDate.getDate()).padStart(2, '0')}`
await this.subscriptionMessageService.sendBookingConfirmedMessage({
openid: user.openid,
bookingId: booking.id,
bookingContent: '预约已确认',
bookingTime: `${dateLabel} ${booking.timeSlot.startTime.slice(0, 5)}`,
courseName: studio.name || '普拉提课程',
bookingEndTime: `${dateLabel} ${booking.timeSlot.endTime.slice(0, 5)}`,
})
} catch (error) {
console.error('Send booking confirmed subscription message failed:', error)
}
}
private async trySendAdminBookingCreatedSubscriptionMessages(
booking: BookingWithRelations,
): Promise<void> {
try {
const admins = await this.prisma.user.findMany({
where: {
role: 'ADMIN',
adminBookingSubscriptionCount: { gt: 0 },
},
select: {
openid: true,
},
})
if (admins.length === 0) {
return
}
const student = await this.prisma.user.findUnique({
where: { id: booking.userId },
select: { nickname: true, phone: true },
})
const studio = await this.studioService.getInfo()
const bookingDate = booking.timeSlot.date
const dateLabel = `${bookingDate.getFullYear()}-${String(bookingDate.getMonth() + 1).padStart(2, '0')}-${String(bookingDate.getDate()).padStart(2, '0')}`
const studentLabel = this.buildAdminBookingStudentLabel(student)
await Promise.allSettled(
admins
.filter((admin) => admin.openid)
.map((admin) => this.subscriptionMessageService.sendAdminBookingCreatedMessage({
openid: admin.openid,
bookingId: booking.id,
bookingContent: `${studentLabel}已预约`.slice(0, 20),
bookingTime: `${dateLabel} ${booking.timeSlot.startTime.slice(0, 5)}`,
courseName: studio.name || '普拉提课程',
bookingEndTime: `${dateLabel} ${booking.timeSlot.endTime.slice(0, 5)}`,
})),
)
} catch (error) {
console.error('Send admin booking created subscription message failed:', error)
}
}
private buildAdminBookingStudentLabel(student: { nickname: string; phone: string | null } | null): string {
const nickname = (student?.nickname || '').trim()
if (nickname) {
return nickname.slice(0, 8)
}
const phone = student?.phone || ''
if (phone.length >= 4) {
return `尾号${phone.slice(-4)}`
}
return '学员'
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common'
import { InviteController } from './invite.controller'
import { InviteService } from './invite.service'
@Module({
controllers: [InviteController],
providers: [InviteService],
exports: [InviteService],
})
export class InviteModule {}

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