67 Commits

Author SHA1 Message Date
richarjiang
fcd9531b3c 0.0.2 2026-09-09 10:11:07 +08:00
richarjiang
f4cee127ce feat: 优化个人中心邀请卡片 2026-09-09 10:10:52 +08:00
richarjiang
99c5c211ad feat: 支持上课统计功能;优化会员管理默认筛选状态 2026-09-09 10:04:29 +08:00
richarjiang
30ebc1c344 feat: 支持邀请好友功能 2026-09-08 22:31:39 +08:00
richarjiang
5a4d2c7a1b perf: 优化 预约详情 UI 2026-09-08 17:47:38 +08:00
richarjiang
b7e339e40c 0.0.1 2026-09-08 17:31:39 +08:00
richarjiang
8e2db3a74a perf: 优化我的课程 UI 2026-09-08 17:31:26 +08:00
richarjiang
f54a12efbd perf: 优化预订页面 UI 2026-09-08 16:35:18 +08:00
richarjiang
b937895bc9 perf: 优化会员卡购买页面 2026-09-08 15:52:34 +08:00
richarjiang
b4b3caac70 perf: 支持课程补录 2026-09-08 15:08:29 +08:00
richarjiang
d1f193e13e feat: 优化首页、个人中心页面 UI 2026-09-08 14:20:43 +08:00
richarjiang
301f9ae385 feat: 优化页面 UI 以及支持个人中心 练习足迹 2026-09-08 11:26:46 +08:00
richarjiang
87d946adb5 fix: 修复月卡无法编辑次数的问题 2026-09-07 18:10:39 +08:00
richarjiang
86ad9ee64f fix: 老师代约只允许已发布课表,去掉临时加开时段
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 17:21:19 +08:00
richarjiang
f5c7b7eaac perf: 优化订阅刷新逻辑 2026-09-07 16:17:57 +08:00
richarjiang
88cd8419c8 feat: 支持同卡种续卡叠加并修复过期登录态
支付成功后将次数或有效期叠加到已有会员卡,首页与详情页引导续卡;接口 401 时同步清掉本地登录态。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 14:04:11 +08:00
richarjiang
801e2f47da feat: 补充月卡退卡按次卡单价结算说明
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 20:23:51 +08:00
richarjiang
15d96c722b feat: 调整默认排课节奏并限制时间选择
第一节 08:00-09:00,之后从 09:30 起每小时一节至 21:30;排课时间仅可选整点或半点。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 20:12:52 +08:00
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
187 changed files with 29063 additions and 6086 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.

157
CLAUDE.md
View File

@@ -1,99 +1,100 @@
# 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`
- 会员管理筛选使用特殊值 `ACTIVE` 表示持有 ACTIVE 状态会员卡的会员用户(页面默认),`NONE` 表示无卡/无有效会员(两者不在卡种枚举中)
- 前端选项硬编码在 `src/pages/admin/members.vue``cardTypeOptions`,需与枚举保持同步
## 架构
### 管理后台 API 模式
- `/admin/members` 支持 `page`, `limit`, `search`, `cardType` 参数
- `cardType=ACTIVE` → 持有任意 ACTIVE 状态会员卡;`cardType=NONE` → 无 ACTIVE 状态会员卡;省略参数查看全部用户;其他值对应 `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 清理过期时段
### Admin Store (`src/stores/admin.ts`)
- 聚合所有管理端 API 调用weekTemplates、cardTypes、studioConfig、members、bookings、orders、stats 等
- 遵循不可变更新原则:`data` 赋值使用展开运算符 `[...newData]`
### 历史课程补录
- `LessonSupplement` 独立记录历史累计课时,归属 user 模块DTO 放 `user/dto`,测试放 `user/__tests__`,前端入口为 `pages/admin/member-supplement.vue`
- 补录不创建预约或时段;只增加累计已完成节数,不推测上课日期、天数、时长,不参与月度统计、活跃网格或邀请奖励。
- 可选择从本人有限次会员卡扣次;补录与扣次必须事务提交,保存实际扣次快照,撤销只返还实际扣次。请求标识用于幂等重试。
- Prisma 迁移按 `YYYYMMDDHHmmss_description/migration.sql` 存放;补录表采用增量迁移,回退说明维护在 `docs/lesson-supplement.md`,不删除审计记录。
### 前端结构
- **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 模块,服务放 `admin/teaching-analytics.service.ts`,测试放 `admin/__tests__`;共享契约放 `shared/src/types/teaching-analytics.ts`,页面为 `pages/admin/analytics.vue`
- 按 TimeSlot.date 所属自然月查询,不按预约创建或核销日期;日期列以 UTC 日历值读取,中国时间用于判断课程结束。
- 当前无老师归属字段统计范围是工作室operatorId 不是授课老师。COMPLETED 是系统完成状态,不代表签到。
- 已上课程按时段去重,时长按已完成时段累加;上课人次按 COMPLETED 预约计数,学员按 userId 去重。取消、未出席、待确认、已确认独立计数。无日期补录不参与。
- 月历、学员排行和会员卡分布统计已完成记录;明细可组合日期、学员、状态筛选。新增测试目录只放该服务的 *.spec.ts。

View File

@@ -117,6 +117,7 @@ echo_info "文件上传完成"
# 6. 服务器端安装依赖并重启
echo_info "服务器部署操作..."
ssh ${SERVER_USER}@${SERVER_HOST} "bash -l" << 'ENDSSH'
set -e
export PATH="$HOME/.nvm/versions/node/v22.17.1/bin:$PATH"
SERVER_PATH="/usr/local/web/mp-pilates-server"
PM2_APP_NAME="mp-pilates-server"

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.

32
docs/invite-marketing.md Normal file
View File

@@ -0,0 +1,32 @@
# 邀请好友领课时
个人中心为所有登录用户展示 6 位邀请码;首次访问 `/invite/code` 时惰性生成,字符避开 0、1、I、O。数据库唯一索引保证唯一条件更新和冲突重试保证并发安全无需回填存量用户。
分享入口使用微信 `button open-type="share"`,分享落地为 `/pages/card/detail?showAll=1&inviteCode=XXXXXX`。落地弹窗自动填码,未领取的列表价格标注「领取后 95 折」。用户确认后绑定一次,禁止自邀和更换邀请人;绑定后体验卡、次卡、期限卡及限时购卡均按当前售价享 95 折,按分四舍五入,后续购买自动享受。订单金额只由服务端计算。
每位好友首次支付非体验卡后,邀请人获得独立的 1 次赠课会员卡365 天有效;邀请人无需先持有会员卡。赠课卡隐藏于商品列表,可从「我的会员卡」预约。体验卡购买与上课均不发奖。每人只奖励一次,续购不重复奖励。
订单记录下单时的邀请人和卡片类别。支付到账、购卡权益、邀请资格更新、赠课及奖励记录处于同一数据库事务。条件更新防止并发回调、同一好友多笔订单重复发奖。历史无归因快照的订单不补发奖励;历史已 QUALIFIED 的邀请保留原资格,不重复奖励。
## 发布
先备份数据库,并在服务端运行 `pnpm exec prisma migrate deploy`,应用 `20260908000000_invite_codes`。该迁移新增可空邀请码、唯一索引、订单归因字段及隐藏赠课卡类型,不更新用户历史数据。
然后运行 `pnpm prisma:generate`,从仓库根目录运行 `pnpm build:shared``pnpm build:server``pnpm build:app`。先发布服务端,再发布小程序。无需新增环境变量。已于 2026-09-08 22:29北京时间执行生产迁移并发布后台小程序尚未发布。
## 真机验收
用两个微信账号验证:个人中心复制和好友分享;分享卡片落地列表与自动填码;未登录确认领取;重启后保留服务端优惠;原价和 95 折金额一致;体验卡支付不赠课;次卡/期限卡首次支付赠课到账;后续购买不再赠课;赠课可预约。还应验证支付失败、取消和重复回调。微信分享与真实微信支付仍需在部署后进行真机验证。
## 验证限制
邀请、支付和限时购卡价格测试通过。已在 390 × 844 的 H5 预览中检查个人中心入口和自动填码落地弹窗H5 预览不代替微信真机验收。全量测试中的旧 SchedulerService 测试缺少 FlashSaleService mock8 项失败,与本次修改无关。`pnpm lint` 因仓库未安装 ESLint 无法运行。
## 本次后台发布记录
- 目标:`129.204.155.94`,目录 `/usr/local/web/mp-pilates-server`PM2 服务 `mp-pilates-server`,端口 3008。
- 使用服务器现有 Node 22.16.0 和 Prisma 5.22.0;现有部署脚本中的 Node 22.17.1 路径不适用于当前服务器,因此按步骤发布构建,未修改线上环境配置及证书。
- 数据库迁移状态正常,新增 3 个字段及独立隐藏赠课卡核验通过。
- 公网 `/api/health``/api/membership/card-types` 返回 200商品列表不包含赠课卡未登录访问 `/api/invite/code` 返回预期的 401。
- 旧构建和 Prisma schema 已归档在服务器 `/usr/local/web/releases/mp-pilates-invite-20260908/previous-build.tgz`,新构建归档为同目录 `release.tgz`
- 本次只进行发布及只读核验,未制造测试用户、邀请关系或支付订单。微信真机分享与支付仍待小程序发布后联调。

29
docs/lesson-supplement.md Normal file
View File

@@ -0,0 +1,29 @@
# 历史课程补录
入口:会员档案 → 上课情况 → 补录上课。一次填写 1999 节,可备注,默认仅补记录,也可选择本人有限次会员卡扣次(含已过期卡)。不限次卡无需扣次;余额不足阻止提交。
一批补录展示为「系统补录 · N 节」,不是 N 个虚拟预约。累计已上课次数(个人中心、会员档案、会员列表已完成)增加 N累计预约、月度节数/天数/时长、最近 30 天网格、邀请奖励保持正常预约口径,因为补录没有真实上课日期。记录时间明确为补录时间。
老师与管理员共用 ADMIN 权限;成员只能读取自己的生效记录。事务保存记录与扣次快照,请求标识唯一,网络重试不重复扣次。撤销软删除,保留操作人与时间,只返还当时实际扣次,过期卡不重新激活。若原卡已转为不限次,撤销阻止并要求先核对卡状态。
## 迁移与发布
新增 `lesson_supplements` 表及关联索引,不修改既有预约数据。按仓库发布流程先执行 `prisma migrate deploy` 并重新生成 Prisma Client、发布后端再发布小程序。开发检查不自动触及生产数据库。
回退:先回退应用代码,保留新增表即可,旧代码不读取该表。若一定要移除表,须先导出审计数据并核对已扣会员卡余额;仅在未写入任何补录的环境可执行 `DROP TABLE lesson_supplements;`。MySQL DDL 不能靠事务回滚,不在生产自动执行删除。
## 本次验证
- 补录与用户统计相关 62 项测试通过,覆盖权限、数量校验、仅补记录、扣次、余额不足、过期卡、重复提交、并发冲突、撤销和统计口径。
- 共享包与后端构建、小程序构建、vue-tsc、Prisma schema 校验通过。
- 使用真实 Vue 页面与示例接口检查 375/320 宽度布局、十节补录、撤销、余额不足及学员历史记录;不替代微信真机与真实数据库联调。
- 全量测试存在原有 SchedulerService 测试依赖缺失:未提供 FlashSaleService8 项失败;其余已执行用例通过。
- 2026-09-08 已经用户授权执行生产迁移及后端发布;小程序仍需单独发布。
## 生产发布记录2026-09-08
- 执行 `pnpm deploy:server`,迁移 `20260908090000_add_lesson_supplements` 成功,全部 4 个迁移处于最新状态。
- 发布前数据库及后端备份保存在服务器 `/usr/local/web/mp-pilates-backups/20260908-145852/`
- 新表读取成功,验收时为 0 条记录;未创建测试补录或修改学员余额。
- 内网及公网 `https://focus.richarjiang.com/api/health` 均返回 `success: true``status: ok`PM2 `mp-pilates-server` 为 online重启计数为 0。
- 部署脚本增加远端 `set -e`,使依赖安装或迁移失败时停止发布。

View File

@@ -0,0 +1,47 @@
# 月度教学统计
入口:管理中心 → 统计分析。页面为 `packages/app/src/pages/admin/analytics.vue`,接口为 `GET /api/admin/teaching-analytics?month=YYYY-MM`
## 统计口径
| 指标 | 定义 |
| --- | --- |
| 已上课程 | COMPLETED 预约关联的时段数,同一时段只计一次 |
| 已完成上课人次 | COMPLETED 预约数 |
| 本月上课学员 | 已完成预约的 userId 去重,不按姓名合并 |
| 授课小时 | 已完成时段的排课分钟数之和除以 60同一时段不重复累计 |
| 上课天数 | 已完成课程日期去重 |
| 人均上课次数 | 上课人次除以上课学员数,无学员时显示横线 |
| 上月参考 | 上一个完整自然月的已上课程节数;当月未结束时明确提示,不给误导性环比 |
| 待核对 | 已过中国时间课程结束时刻、仍待确认或已确认的预约 |
月份以 TimeSlot.date 的日历日期为准,查询使用月初包含、下月初不包含的区间。预约创建时间、完成时间和取消时间不影响月份归属。取消、未出席、待确认和已确认不计入已上课程。
当前模型没有老师归属、课程名称或课程类别,因此统计范围明确为工作室;不能把 operatorId 当作授课老师。用卡分布代表预约所用会员卡的当前卡种名称,不代表课程类别、实际消课收入或老师课酬。同名卡种合并显示。
系统存在自动完成预约任务COMPLETED 不等于现场签到,不提供签到率。没有课程日期的历史累计补录不计入本报告。
## 页面与交互
- 米白底、松绿色总览、宋体标题与衬线数字,延续原有课表风格。
- 月份选择器、前后月切换、回到本月、手动刷新与下拉刷新;支持 2000—2099 年。
- 月历显示每天已完成课程节数,点击日期定位明细,再次点击解除日期条件。
- 学员排行支持姓名搜索,显示上课次数、天数与最近上课日期;点击查看该学员全月已完成明细。
- 明细支持日期、学员、预约状态组合筛选;每个状态显示当前日期/学员条件内的记录数。清除筛选恢复全月全部记录。
- 明细显示课程日期、起止时间、学员、用卡与状态,点击姓名进入会员详情。
- 排行每次增加 10 人、明细每次增加 20 条,避免一次渲染过多节点。接口返回当月完整记录,不以列表显示上限截断统计。
- 切月清空旧数据和筛选,请求序号防止慢响应覆盖新月份。错误提示与空月独立显示,失败不伪装成零数据。
## 实现边界与验证
接口继承管理中心的 JWT 和 ADMIN 权限保护。一次查询读取当月及上月必要关联字段,无逐学员查询;只向前端返回当月明细与两个月的汇总。未新增数据库表或迁移。
服务测试覆盖课程去重、同名不同学员、状态排除、空月、跨年、闰年、月会员管理 页面中,默认是查看全部用户,这个查看效率太低了,我希望默认看到的是会员用户增加这么一个筛选, 至于新用户,我可以选择筛选无卡用户就行了,这样的体验会更加好一些参数验证、中国时间结束判断以及接口权限元数据。
验证通过:共享包构建、后端构建、前端类型检查、微信小程序构建;新增统计测试 12 项通过。全量测试 231 项通过,原有 scheduler 测试 8 项因缺少 FlashSaleService mock 失败。
使用真实 Vue 页面与 SCSS、模拟数据进行了 390px 手机宽度预览,验证学员钻取、日期筛选、快速切月、空月和错误状态。浏览器预览仅模拟小程序容器;微信真机、原生月份选择器以及线上数据联调仍需发布前验收。
设计参考:[TeamUp 出勤报表](https://support.goteamup.com/en/articles/9327465-reports-class-attendances-all-attendances),借鉴按学员、日期和状态追溯出勤记录的交互,不复制其产品界面。
上线需要部署后端并发布小程序,本次仅实现与本地验证,未执行生产部署。

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

@@ -22,5 +22,6 @@
"@prisma/engines",
"prisma"
]
}
},
"version": "0.0.2"
}

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,84 @@
<template>
<view class="about-section">
<view class="section-header">
<view>
<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>
<view class="teacher-content">
<view class="teacher-heading">
<view>
<view class="name-row">
<text class="teacher-name">{{ teacher.name }}</text>
</view>
<text class="teacher-title">{{ teacher.title }}</text>
</view>
</view>
<view class="specialty-row">
<text class="specialty-text">{{ teacher.specialties.join(' · ') }}</text>
</view>
<text class="teacher-intro">{{ teacher.intro }}</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 { margin: 36rpx 32rpx 0; }
.section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20rpx; }
.section-title { font-size: 30rpx; font-weight: 500; color: #514943; }
.section-more { font-size: 23rpx; color: #8b817b; padding: 8rpx 0; }
.teacher-card { padding: 26rpx; background: #f2ebe5; border-radius: 28rpx; }
.teacher-main { display: flex; align-items: flex-start; gap: 24rpx; }
.cover-wrap { width: 164rpx; height: 230rpx; border-radius: 18rpx; overflow: hidden; background: #e1d4c8; flex-shrink: 0; }
.teacher-cover { width: 100%; height: 100%; }
.teacher-content { flex: 1; min-width: 0; }
.name-row { margin-bottom: 8rpx; }
.teacher-name { font-size: 34rpx; font-weight: 500; color: #514943; }
.teacher-title { font-size: 22rpx; color: #8b817b; line-height: 1.5; }
.specialty-row { margin: 14rpx 0 10rpx; }
.specialty-text { font-size: 21rpx; color: #617d73; line-height: 1.6; }
.teacher-intro { font-size: 22rpx; line-height: 1.7; color: #786b61; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.action-row { display: flex; gap: 16rpx; margin-top: 24rpx; }
.secondary-btn, .primary-btn { flex: 1; height: 68rpx; border-radius: 999rpx; display: flex; align-items: center; justify-content: center; }
.secondary-btn { background: #fbf7f3; }
.primary-btn { background: #6b8276; }
.secondary-btn-text { font-size: 24rpx; color: #78675c; }
.primary-btn-text { font-size: 24rpx; font-weight: 400; color: #fff; }
</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>
@@ -36,15 +36,12 @@
<!-- Membership card selection -->
<view class="card-section">
<view class="section-label-row">
<text class="section-label">选择扣课会员卡</text>
<text class="section-label">选择会员卡</text>
</view>
<!-- Single membership -->
<view v-if="memberships.length === 1" class="card-single">
<view class="card-item selected">
<view class="card-icon-wrap">
<text class="card-icon">💳</text>
</view>
<view class="card-info">
<text class="card-name">{{ memberships[0].cardType.name }}</text>
<text class="card-remain" v-if="memberships[0].remainingTimes !== null">
@@ -61,7 +58,7 @@
</view>
<!-- Multiple memberships picker -->
<view v-else-if="memberships.length > 1" class="card-picker-wrap">
<scroll-view v-else-if="memberships.length > 1" class="card-picker-wrap" scroll-y>
<view
v-for="m in memberships"
:key="m.id"
@@ -69,9 +66,6 @@
:class="{ selected: selectedMembershipId === m.id }"
@tap="selectedMembershipId = m.id"
>
<view class="card-icon-wrap">
<text class="card-icon">💳</text>
</view>
<view class="card-info">
<text class="card-name">{{ m.cardType.name }}</text>
<text class="card-remain" v-if="m.remainingTimes !== null">
@@ -85,7 +79,7 @@
<text class="check-icon"></text>
</view>
</view>
</view>
</scroll-view>
<!-- No memberships fallback (should not normally appear) -->
<view v-else class="no-card-tip">
@@ -96,7 +90,9 @@
<!-- Deduction tip -->
<view class="deduction-tip" v-if="selectedMembership">
<text class="deduction-text">
确认后将从{{ selectedMembership.cardType.name }}扣除 1 次课时
{{ selectedMembership.remainingTimes === null
? `${selectedMembership.cardType.name}」有效期内不限次`
: `确认后将从「${selectedMembership.cardType.name}」扣除 1 次课时` }}
</text>
</view>
@@ -105,13 +101,15 @@
<view class="btn-outline" @tap="handleCancel">
<text class="btn-outline-text">取消</text>
</view>
<view
<button
class="btn-confirm"
:class="{ disabled: !selectedMembershipId }"
:disabled="!selectedMembershipId || requestingSubscribe"
:loading="requestingSubscribe"
@tap="handleConfirm"
>
<text class="btn-confirm-text">确认预约</text>
</view>
</button>
</view>
</view>
</view>
@@ -120,10 +118,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,13 +133,14 @@ const emit = defineEmits<{
}>()
const selectedMembershipId = ref<string>('')
const requestingSubscribe = ref(false)
// Auto-select the first membership when popup opens or memberships list changes
watch(
[() => props.visible, () => props.memberships],
([visible, memberships]) => {
if (visible && memberships.length > 0) {
selectedMembershipId.value = memberships[0].id
if (visible) {
selectedMembershipId.value = memberships[0]?.id ?? ''
}
},
{ immediate: true },
@@ -150,10 +150,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,
})
}
@@ -169,261 +183,45 @@ function handleMaskTap() {
</script>
<style lang="scss" scoped>
.popup-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 1000;
display: flex;
align-items: flex-end;
justify-content: center;
}
.popup-panel {
width: 100%;
background: #fff;
border-radius: 32rpx 32rpx 0 0;
padding: 32rpx 32rpx calc(32rpx + env(safe-area-inset-bottom));
display: flex;
flex-direction: column;
gap: 0;
}
.popup-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 32rpx;
}
.popup-title {
font-size: 34rpx;
font-weight: 700;
color: #1a1a1a;
}
.close-btn {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
border-radius: 50%;
}
.close-icon {
font-size: 24rpx;
color: #999;
}
/* Info rows */
.info-section {
display: flex;
flex-direction: column;
gap: 20rpx;
margin-bottom: 28rpx;
}
.info-row {
display: flex;
flex-direction: row;
align-items: center;
}
.info-label {
font-size: 28rpx;
color: #999;
width: 80rpx;
flex-shrink: 0;
}
.info-value {
font-size: 28rpx;
color: #222;
font-weight: 500;
}
.divider {
height: 1rpx;
background: #f0f0f0;
margin: 8rpx 0 28rpx;
}
/* Card selection */
.card-section {
display: flex;
flex-direction: column;
gap: 16rpx;
margin-bottom: 24rpx;
}
.section-label-row {
margin-bottom: 8rpx;
}
.section-label {
font-size: 28rpx;
color: #666;
font-weight: 500;
}
.card-single,
.card-picker-wrap {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.popup-mask { position: fixed; inset: 0; background: rgba(56, 48, 42, 0.32); z-index: 1000; display: flex; align-items: flex-end; justify-content: center; }
.popup-panel { width: 100%; box-sizing: border-box; background: #fbf9f6; border-radius: 36rpx 36rpx 0 0; padding: 28rpx 32rpx calc(24rpx + env(safe-area-inset-bottom)); }
.popup-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24rpx; }
.popup-title { font-size: 32rpx; font-weight: 500; color: #514943; }
.close-btn { width: 60rpx; height: 60rpx; display: flex; align-items: center; justify-content: center; }
.close-icon { font-size: 26rpx; color: #8b817b; }
.info-section { padding: 24rpx; background: #f1e8e1; border-radius: 24rpx; display: flex; flex-direction: column; gap: 14rpx; }
.info-row { display: flex; align-items: center; gap: 20rpx; }
.info-label { font-size: 24rpx; color: #8b7b70; width: 64rpx; flex-shrink: 0; }
.info-value { font-size: 26rpx; color: #6f5c50; font-weight: 400; }
.divider { height: 24rpx; }
.card-section { display: flex; flex-direction: column; gap: 16rpx; }
.section-label { font-size: 26rpx; color: #514943; font-weight: 500; }
.card-picker-wrap { height: 250rpx; }
.card-item {
display: flex;
flex-direction: row;
align-items: center;
padding: 24rpx 20rpx;
border-radius: 16rpx;
border: 2rpx solid #f0f0f0;
background: #fafafa;
gap: 20rpx;
transition: border-color 0.15s, background 0.15s;
&.selected {
border-color: #c9a87c;
background: #fffbf5;
}
display: flex; align-items: center; padding: 22rpx 24rpx; border-radius: 24rpx;
border: 2rpx solid #eee8e3; background: #fff; gap: 20rpx; box-sizing: border-box;
&.selected { border-color: #a5b8ab; background: #f0f4ee; }
}
.card-icon-wrap {
width: 60rpx;
height: 60rpx;
border-radius: 14rpx;
background: linear-gradient(135deg, #d4b896, #c9a87c);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.card-icon {
font-size: 32rpx;
}
.card-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 6rpx;
}
.card-name {
font-size: 28rpx;
font-weight: 600;
color: #222;
}
.card-remain {
font-size: 22rpx;
color: #999;
}
.check-mark {
width: 44rpx;
height: 44rpx;
border-radius: 50%;
background: #c9a87c;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.check-icon {
font-size: 24rpx;
color: #fff;
font-weight: 700;
}
.no-card-tip {
padding: 24rpx;
text-align: center;
}
.no-card-text {
font-size: 26rpx;
color: #bbb;
}
/* Deduction tip */
.deduction-tip {
background: #fffbf0;
border-radius: 12rpx;
padding: 16rpx 20rpx;
margin-bottom: 28rpx;
}
.deduction-text {
font-size: 24rpx;
color: #c9a87c;
line-height: 1.5;
}
/* Action buttons */
.action-row {
display: flex;
flex-direction: row;
gap: 20rpx;
margin-top: 8rpx;
}
.btn-outline {
flex: 1;
height: 88rpx;
border-radius: 44rpx;
border: 2rpx solid #e0e0e0;
display: flex;
align-items: center;
justify-content: center;
&:active {
background: #f5f5f5;
}
}
.btn-outline-text {
font-size: 30rpx;
color: #666;
font-weight: 500;
}
.card-picker-wrap .card-item { margin-bottom: 12rpx; }
.card-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
.card-name { font-size: 26rpx; font-weight: 400; color: #514943; overflow-wrap: anywhere; }
.card-remain { font-size: 22rpx; color: #8b817b; }
.check-mark { width: 36rpx; height: 36rpx; border-radius: 50%; background: #6b8276; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.check-icon { font-size: 22rpx; color: #fff; }
.no-card-tip { padding: 24rpx; text-align: center; }
.no-card-text { font-size: 24rpx; color: #8b817b; }
.deduction-tip { padding: 18rpx 4rpx; }
.deduction-text { font-size: 22rpx; color: #8b817b; line-height: 1.6; }
.action-row { display: flex; gap: 20rpx; margin-top: 12rpx; }
.btn-outline { flex: 1; height: 88rpx; border-radius: 999rpx; background: #f0eae4; display: flex; align-items: center; justify-content: center; }
.btn-outline-text { font-size: 28rpx; color: #78675c; font-weight: 400; }
.btn-confirm {
flex: 2;
height: 88rpx;
border-radius: 44rpx;
background: #c9a87c;
display: flex;
align-items: center;
justify-content: center;
&:active {
opacity: 0.85;
}
&.disabled {
background: #e0e0e0;
}
}
.btn-confirm-text {
font-size: 30rpx;
color: #fff;
font-weight: 600;
letter-spacing: 1rpx;
.disabled & {
color: #bbb;
}
flex: 2; height: 88rpx; padding: 0; margin: 0; border: none; line-height: 1;
border-radius: 999rpx; background: #6b8276;
display: flex; align-items: center; justify-content: center;
&::after { border: none; }
&:active { background: #597264; }
&.disabled { background: #d9dfd8; }
}
.btn-confirm-text { font-size: 28rpx; color: #fff; font-weight: 500; .disabled & { color: #677467; } }
</style>

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

@@ -3,7 +3,7 @@
<!-- Section header -->
<view class="section-header">
<text class="section-title">会员卡</text>
<text class="section-action" @tap="goToAllCards">全部</text>
<text class="section-action" @tap="goToAllCards">全部 </text>
</view>
<!-- Loading skeleton -->
@@ -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,48 @@
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>
<text class="cover-label">{{ card.type === CardTypeCategory.TRIAL ? '体验' : card.type === CardTypeCategory.DURATION ? '期限卡' : '次卡' }}</text>
</template>
</view>
<!-- Card info aligns with card-cover height -->
<view class="card-info">
<view class="info-top">
<view class="card-name-row">
<text class="card-name">{{ card.name }}</text>
<text v-if="isRenewable(card)" class="renew-tag"></text>
</view>
<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(invite.price(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>
<text v-if="invite.eligible" class="renew-tag">95 </text>
<!-- Arrow -->
<text class="card-arrow"></text>
</view>
</view>
@@ -65,24 +83,51 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useInviteStore } from '../stores/invite'
import { computed, 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'
import { useUserStore } from '../stores/user'
const invite = useInviteStore()
const userStore = useUserStore()
const cardTypes = ref<CardType[]>([])
const loading = ref(false)
const hasLoaded = ref(false)
const renewableCardTypeIds = computed(() => {
const ids = new Set<string>()
for (const membership of userStore.memberships) {
if (membership.cardType.type !== CardTypeCategory.TRIAL) {
ids.add(membership.cardTypeId)
}
}
return ids
})
function isRenewable(card: CardType): boolean {
return card.type !== CardTypeCategory.TRIAL && renewableCardTypeIds.value.has(card.id)
}
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,232 +143,41 @@ 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;
padding-bottom: 20rpx;
}
/* ── Section header ── */
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx 32rpx 20rpx;
}
.section-title {
font-size: 36rpx;
font-weight: 700;
color: #222;
}
.section-action {
font-size: 26rpx;
color: #999;
padding: 8rpx 24rpx;
border: 1rpx solid #ddd;
border-radius: 24rpx;
}
/* ── Card list ── */
.card-list {
padding: 0 32rpx;
}
.card-row {
display: flex;
align-items: center;
gap: 24rpx;
padding: 24rpx 0;
border-bottom: 1rpx solid #f0f0f0;
&:last-child {
border-bottom: none;
}
}
/* ── Thumbnail ── */
.card-thumb {
width: 200rpx;
height: 140rpx;
border-radius: 12rpx;
overflow: hidden;
flex-shrink: 0;
position: relative;
}
.thumb-fallback {
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);
}
.thumb--duration .thumb-fallback {
background: linear-gradient(135deg, #6c3483, #9b59b6);
}
.thumb--trial .thumb-fallback {
background: linear-gradient(135deg, #7d6608, #c9a87c);
}
.thumb-name {
font-size: 22rpx;
font-weight: 600;
color: #ffffff;
text-align: center;
line-height: 1.3;
word-break: break-all;
}
.thumb-price {
font-size: 24rpx;
font-weight: 700;
color: #ffffff;
}
/* ── Card info ── */
.card-info {
flex: 1;
min-width: 0;
}
.card-name {
display: block;
font-size: 30rpx;
font-weight: 600;
color: #222;
margin-bottom: 8rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-validity {
display: block;
font-size: 24rpx;
color: #999;
margin-bottom: 12rpx;
}
.price-row {
display: flex;
align-items: baseline;
gap: 4rpx;
}
.price-label {
font-size: 24rpx;
color: #e53935;
}
.price-symbol {
font-size: 24rpx;
color: #e53935;
font-weight: 600;
}
.price-current {
font-size: 40rpx;
font-weight: 800;
color: #e53935;
}
.price-original {
font-size: 22rpx;
color: #bbb;
text-decoration: line-through;
margin-left: 12rpx;
}
/* ── Skeleton ── */
.skeleton-row {
display: flex;
align-items: center;
gap: 24rpx;
padding: 24rpx 0;
border-bottom: 1rpx solid #f0f0f0;
}
.skeleton-thumb {
width: 200rpx;
height: 140rpx;
border-radius: 12rpx;
flex-shrink: 0;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
.skeleton-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.skeleton-line {
height: 24rpx;
border-radius: 6rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
&--title {
width: 70%;
height: 30rpx;
}
&--sub {
width: 40%;
}
&--price {
width: 50%;
height: 36rpx;
}
}
@keyframes shimmer {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}
/* ── Empty state ── */
.empty-state {
padding: 80rpx;
display: flex;
align-items: center;
justify-content: center;
}
.empty-text {
font-size: 28rpx;
color: #bbb;
}
.card-shop { margin: 36rpx 32rpx 0; }
.section-header { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; margin-bottom: 20rpx; }
.section-title { font-size: 30rpx; font-weight: 500; color: #514943; }
.section-action { font-size: 23rpx; color: #8b817b; padding: 8rpx 0; }
.card-list { padding: 0 24rpx; border-radius: 28rpx; background: #fff; border: 1rpx solid #eee8e3; }
.card-row { display: flex; align-items: center; gap: 20rpx; padding: 26rpx 0; border-bottom: 1rpx solid #f0ebe6; &:last-child { border: none; } }
.card-cover { width: 132rpx; height: 148rpx; border-radius: 16rpx; overflow: hidden; flex-shrink: 0; display: flex; align-items: center; justify-content: center; background: #f0e6de; }
.card-cover-img { width: 100%; height: 100%; }
.cover--times { background: #f0e6de; }
.cover--duration { background: #e9ede5; }
.cover--trial { background: #f2e4e0; }
.cover-label { font-family: 'Songti SC', 'STSong', serif; font-size: 28rpx; color: #857062; letter-spacing: 3rpx; }
.card-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 14rpx; }
.info-top { display: flex; flex-direction: column; gap: 8rpx; }
.card-name-row { display: flex; align-items: baseline; gap: 8rpx; }
.card-name { min-width: 0; font-size: 27rpx; font-weight: 400; color: #514943; line-height: 1.5; overflow-wrap: anywhere; }
.renew-tag { flex-shrink: 0; font-size: 18rpx; color: #617d73; background: #edf2ec; padding: 3rpx 8rpx; border-radius: 6rpx; }
.card-validity { font-size: 21rpx; color: #8b817b; }
.info-bottom { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8rpx 16rpx; }
.card-times { display: flex; align-items: baseline; gap: 4rpx; }
.card-times-value { font-size: 24rpx; color: #6f655e; }
.card-times-unit { font-size: 20rpx; color: #8b817b; }
.price-row { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8rpx; }
.price-current { font-size: 30rpx; font-weight: 500; color: #8b6c5b; font-variant-numeric: tabular-nums; }
.price-original { font-size: 20rpx; color: #a59b93; text-decoration: line-through; }
.card-arrow { font-size: 32rpx; color: #b2a79e; flex-shrink: 0; }
.skeleton-card-cover { width: 132rpx; height: 148rpx; border-radius: 16rpx; flex-shrink: 0; }
.skeleton-info { flex: 1; display: flex; flex-direction: column; gap: 16rpx; }
.skeleton-line { height: 24rpx; width: 70%; border-radius: 8rpx; &--sub { width: 50%; } }
.skeleton-line, .skeleton-card-cover { background: linear-gradient(90deg, #f0eae5 25%, #faf7f3 50%, #f0eae5 75%); background-size: 400% 100%; animation: shimmer 1.4s infinite; }
.empty-state { padding: 48rpx 24rpx; border-radius: 24rpx; background: #f3efea; text-align: center; }
.empty-text { font-size: 24rpx; color: #8b817b; }
</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,17 +1,20 @@
<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>
<text class="day">{{ getDayNumber(item.date) }}</text>
<text class="month">{{ getMonthNumber(item.date) }}</text>
<text v-if="variant !== 'soft' || getDayNumber(item.date) === '1'" class="month">{{ getMonthNumber(item.date) }}</text>
</view>
</view>
</scroll-view>
@@ -25,6 +28,7 @@ import { getDateRange } from '../utils/format'
interface Props {
modelValue: string
variant?: 'default' | 'booking' | 'soft'
}
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,64 @@ 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;
}
}
}
}
}
.date-selector.date-selector--soft {
padding: 8rpx 0 24rpx;
background: #fbf9f6;
border-bottom: none;
.track { padding: 0 32rpx; gap: 12rpx; }
.date-item {
min-width: 0; width: 86rpx; height: 116rpx; padding: 0;
box-sizing: border-box; border-radius: 24rpx;
background: transparent; gap: 12rpx;
.weekday { font-size: 22rpx; color: #8b817b; }
.day { font-size: 34rpx; font-weight: 400; color: #514943; }
&.active {
background: #eee3dc;
.weekday, .day { color: #705b4f; }
.day { font-weight: 500; }
}
&.today:not(.active) .weekday { color: #617d73; font-weight: 400; }
}
}
</style>

View File

@@ -0,0 +1,185 @@
<template>
<view v-if="flashSales.length" class="flash-sale-section">
<!-- Section header -->
<view class="section-header">
<view class="header-left">
<text class="section-title">限时秒杀</text>
<text v-if="hasOngoing" class="live-note">进行中</text>
</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(invite.price(sale.flashPrice)) }}</text>
</view>
<text v-if="invite.eligible" class="original-price">好友 95 </text>
<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 { useInviteStore } from "../stores/invite"
const invite = useInviteStore()
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 { margin: 36rpx 32rpx 0; }
.section-header { margin-bottom: 20rpx; }
.header-left { display: flex; align-items: baseline; justify-content: space-between; gap: 16rpx; }
.section-title { font-size: 30rpx; font-weight: 500; color: #514943; }
.live-note { font-size: 22rpx; color: #9b7b66; }
.flash-scroll { width: 100%; white-space: nowrap; }
.flash-card-list { display: inline-flex; gap: 20rpx; }
.flash-card { width: 400rpx; border-radius: 28rpx; overflow: hidden; border: 1rpx solid #e7ded5; flex-shrink: 0; display: inline-flex; flex-direction: column; white-space: normal; background: #fff; }
.card-top { padding: 20rpx 24rpx; background: #f1e6de; display: flex; flex-direction: column; gap: 12rpx; }
.card--upcoming .card-top { background: #eaf0e8; }
.card--soldout .card-top, .card--ended .card-top { background: #eeeae5; }
.phase-badge-text { font-size: 22rpx; color: #7c6656; }
.countdown-row, .countdown-blocks { display: flex; align-items: baseline; gap: 8rpx; }
.countdown-label { font-size: 20rpx; color: #8b817b; }
.cd-block, .cd-sep { font-size: 24rpx; color: #7c6656; font-variant-numeric: tabular-nums; }
.card-body { padding: 24rpx; display: flex; flex-direction: column; gap: 10rpx; }
.card-title { font-size: 28rpx; font-weight: 500; color: #514943; line-height: 1.5; overflow-wrap: anywhere; }
.card-type-name { font-size: 22rpx; color: #8b817b; }
.price-area { display: flex; align-items: baseline; flex-wrap: wrap; gap: 12rpx; margin-top: 8rpx; }
.flash-price-row { display: flex; align-items: baseline; gap: 4rpx; }
.flash-currency { font-size: 22rpx; color: #8b6c5b; }
.flash-price { font-size: 38rpx; font-weight: 500; color: #8b6c5b; }
.original-price { font-size: 21rpx; color: #a59b93; text-decoration: line-through; }
.stock-area { margin-top: 12rpx; display: flex; flex-direction: column; gap: 10rpx; }
.stock-bar { height: 6rpx; background: #f3efea; border-radius: 6rpx; overflow: hidden; }
.stock-fill { height: 100%; border-radius: 6rpx; background: #bea28e; }
.stock-text { font-size: 20rpx; color: #8b817b; }
</style>

View File

@@ -0,0 +1,71 @@
<template>
<view class="invite-entry">
<button class="invite-heading" :disabled="opening" @tap="open" aria-label="邀请好友查看邀请码与邀请权益">
<view class="entry-copy">
<text class="entry-title">邀请好友领课时</text>
<text class="entry-caption">好友享 95 · {{ user.loggedIn && invite.activity ? `已获 ${invite.activity.rewardedTimes} 节赠课` : '一起练,得赠课' }}</text>
</view>
<view class="invite-count">
<text class="count-number">{{ user.loggedIn && invite.activity ? invite.activity.referrals.length : '—' }}<text v-if="user.loggedIn"> </text></text>
<text class="count-label">{{ user.loggedIn ? '已邀请' : '登录查看' }}</text>
</view>
<text class="entry-arrow"></text>
</button>
<view v-if="visible" class="veil" @tap="visible = false" @touchmove.stop.prevent>
<view class="sheet" @tap.stop>
<button class="close" @tap="visible = false" aria-label="关闭">×</button>
<text class="eyebrow">A LITTLE GIFT, SHARED</text>
<text class="headline">好朋友好好练</text>
<text class="subtitle">把你的练习日常分享给在意的人</text>
<view class="benefits"><view><text class="benefit-num">1<text> </text></text><text>你得免费课</text></view><view><text class="benefit-num">95<text> </text></text><text>好友购卡优惠</text></view></view>
<view class="ticket" @tap="copy"><text>你的专属邀请码 · 点击复制</text><text class="ticket-code">{{ invite.code }}</text></view>
<text class="rules">好友确认邀请码体验卡次卡和期限卡均享 95 每位好友首次成功购买非体验卡你得 1 节免费课购买或完成体验课不触发赠课</text>
<text class="rules">赠课自动存入我的会员卡365 天内可预约每位好友仅绑定一位邀请人</text>
<button class="share" open-type="share" :disabled="!invite.code">分享给微信好友</button>
<text class="footnote">送朋友一份心意也给自己一次练习</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useInviteStore } from '../stores/invite'
import { useUserStore } from '../stores/user'
import { getErrorMessage } from '../utils/auth'
const invite = useInviteStore()
const user = useUserStore()
const visible = ref(false)
const opening = ref(false)
async function open() {
if (opening.value) return
opening.value = true
try {
if (!user.loggedIn) await user.login()
await Promise.all([invite.refresh(), invite.refreshActivity().catch(() => {})])
visible.value = true
} catch (err) { uni.showToast({ title: getErrorMessage(err, '暂时无法获取邀请码,请重试'), icon: 'none' }) }
finally { opening.value = false }
}
function copy() {
if (!invite.code) { open(); return }
uni.setClipboardData({ data: invite.code })
}
</script>
<style scoped lang="scss">
.invite-entry { margin: 16rpx 32rpx; padding: 0; background: #eaf0e7; border: 1rpx solid #d9e3d4; border-radius: 20rpx; color: #435b4c; }
.invite-heading { width: 100%; min-height: 120rpx; box-sizing: border-box; margin: 0; padding: 18rpx 24rpx; background: transparent; border-radius: 20rpx; color: inherit; display: flex; gap: 20rpx; text-align: left; align-items: center; line-height: 1.5; &::after { border: none; } &:active { background: #e2eadf; } }
.entry-copy { flex: 1; min-width: 0; }
.entry-title { display: block; font-size: 28rpx; font-weight: 500; }
.entry-caption { display: block; margin-top: 6rpx; font-size: 21rpx; color: #6b7d65; }
.invite-count { flex-shrink: 0; text-align: right; }
.count-number { display: block; font-family: Georgia, serif; font-size: 34rpx; line-height: 1.2; font-variant-numeric: tabular-nums; text { font-size: 20rpx; } }
.count-label { display: block; margin-top: 5rpx; font-size: 19rpx; color: #6b7d65; }
.entry-arrow { flex-shrink: 0; font-size: 30rpx; color: #87967f; }
.eyebrow { display: block; font-size: 19rpx; letter-spacing: 3rpx; color: #788976; }
.veil { position: fixed; inset: 0; z-index: 400; background: rgba(32,43,35,.48); display: flex; align-items: center; padding: 30rpx; }
.sheet { position: relative; width: 100%; max-height: 85vh; overflow-y: auto; box-sizing: border-box; padding: 48rpx 34rpx 32rpx; border-radius: 32rpx; background: #fcfaf5; }.close { position: absolute; right: 18rpx; top: 12rpx; margin: 0; background: transparent; color: #7b8477; font-size: 36rpx; }.close::after,.share::after { border: none; }
.headline { display: block; margin-top: 20rpx; font-family: 'Songti SC',serif; font-size: 46rpx; }.subtitle { display: block; margin-top: 12rpx; font-size: 23rpx; color: #87907f; }
.benefits { display: flex; margin: 30rpx 0; padding: 25rpx 0; background: #eaf0e5; border-radius: 22rpx; }.benefits>view { flex: 1; text-align: center; font-size: 24rpx; }.benefits>view+view { border-left: 1rpx solid #cbd8c5; }.benefit-num { display: block; font: 66rpx Georgia,serif; margin-bottom: 10rpx; }.benefit-num text { font-size: 26rpx; }
.ticket { padding: 22rpx; text-align: center; border: 1rpx dashed #b7c5af; border-radius: 16rpx; font-size: 21rpx; color: #7a8772; }.ticket-code { display: block; margin-top: 12rpx; font: 42rpx monospace; letter-spacing: 10rpx; color: #435b4c; }
.rules { display: block; margin-top: 18rpx; font-size: 21rpx; line-height: 1.8; color: #7d8076; }.share { margin-top: 26rpx; border-radius: 999rpx; background: #526e58; color: #fff; font-size: 27rpx; line-height: 86rpx; }.footnote { display: block; text-align: center; margin-top: 16rpx; font-size: 19rpx; color: #93978a; }
</style>

View File

@@ -0,0 +1,40 @@
<template>
<view class="supplements">
<view v-for="item in records" :key="item.id" class="record" :class="{ 'record--revoked': item.revokedAt }">
<view class="record-head">
<view class="record-title"><text>系统补录</text><text class="record-sub">{{ item.revokedAt ? '已撤销' : '历史课程' }}</text></view>
<text class="record-quantity">{{ item.quantity }}<text class="record-unit"> </text></text>
</view>
<text class="record-date">{{ formatDateTimeFull(item.createdAt) }} 补录</text>
<text class="record-note">{{ item.deductedTimes ? `${item.cardName} · ${item.revokedAt ? '已返还' : '扣除'} ${item.deductedTimes}` : '仅补记录 · 未扣会员卡' }}</text>
<text v-if="item.remark" class="record-note">{{ item.remark }}</text>
<view v-if="editable" class="record-footer">
<text class="record-operator"> {{ item.operatorName }} 补录</text>
<button v-if="!item.revokedAt" class="revoke" :disabled="busy" @tap="emit('revoke', item)">撤销补录</button>
<text v-else class="record-operator">{{ formatDateTimeFull(item.revokedAt) }} 撤销</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import type { LessonSupplementRecord } from '@mp-pilates/shared'
import { formatDateTimeFull } from '../utils/format'
defineProps<{ records: readonly LessonSupplementRecord[]; editable?: boolean; busy?: boolean }>()
const emit = defineEmits<{ revoke: [record: LessonSupplementRecord] }>()
</script>
<style lang="scss" scoped>
.supplements { display: flex; flex-direction: column; }
.record { padding: 24rpx 0; border-bottom: 1rpx solid #eee8e3; color: #514943; }
.record:last-child { border-bottom: none; }
.record--revoked { color: #8b817b; }
.record-head { display: flex; align-items: center; justify-content: space-between; gap: 20rpx; }
.record-title { display: flex; flex-direction: column; gap: 8rpx; font-size: 28rpx; }
.record-sub { font-size: 20rpx; color: #8b817b; }
.record-quantity { flex-shrink: 0; font-size: 42rpx; font-variant-numeric: tabular-nums; color: #617d73; }
.record-unit { font-size: 22rpx; }
.record-date { display: block; margin-top: 20rpx; color: #8b817b; font-size: 21rpx; }
.record-note { display: block; margin-top: 12rpx; font-size: 23rpx; color: #786b61; line-height: 1.6; overflow-wrap: anywhere; }
.record-footer { margin-top: 20rpx; padding-top: 16rpx; border-top: 1rpx solid #eee8e3; display: flex; align-items: center; justify-content: space-between; gap: 16rpx; flex-wrap: wrap; }
.record-operator { color: #8b817b; font-size: 21rpx; }
.revoke { margin: 0; padding: 8rpx 12rpx; font-size: 22rpx; line-height: 1.5; background: transparent; color: #967561; &::after { border: none; } }
</style>

View File

@@ -0,0 +1,78 @@
<template>
<view class="month-divider">
<!-- 左侧细线 -->
<view class="month-divider__line" />
<!-- 月份主体 -->
<view class="month-divider__body">
<!-- 月份装饰一个小圆点 + 月份名 -->
<view class="month-divider__dot" />
<text class="month-divider__label">{{ label }}</text>
<view class="month-divider__dot" />
</view>
<!-- 右侧细线 -->
<view class="month-divider__line" />
<!-- 副信息节气或描述 -->
<text v-if="hint" class="month-divider__hint">{{ hint }}</text>
</view>
</template>
<script setup lang="ts">
defineProps<{
/** 主标签,例如 "十月" */
label: string
/** 副信息,例如 "共 3 节" */
hint?: string
}>()
</script>
<style lang="scss" scoped>
.month-divider {
display: flex;
align-items: center;
gap: 16rpx;
margin: 48rpx 32rpx 24rpx;
&__line {
flex: 1;
height: 1rpx;
background: linear-gradient(
to right,
transparent 0%,
rgba(155, 138, 117, 0.25) 30%,
rgba(155, 138, 117, 0.45) 100%
);
}
&__body {
display: flex;
align-items: center;
gap: 14rpx;
padding: 0 4rpx;
}
&__dot {
width: 6rpx;
height: 6rpx;
border-radius: 50%;
background: #b09a83;
}
&__label {
font-family: "Songti SC", "STSong", "Noto Serif SC", "Source Han Serif SC", serif;
font-size: 28rpx;
font-weight: 400;
color: #6f645a;
letter-spacing: 8rpx;
}
&__hint {
font-size: 22rpx;
color: #a89d92;
letter-spacing: 1rpx;
font-variant-numeric: tabular-nums;
}
}
</style>

View File

@@ -0,0 +1,382 @@
<template>
<view class="hero" :class="`hero--${tone}`" @tap="handleTap">
<!-- 背景纹理层 -->
<view class="hero__grain" />
<!-- 装饰右上角的弧线暗示月相 -->
<view class="hero__moon" />
<view class="hero__main">
<!-- 左侧大日期块 -->
<view class="hero__date">
<text class="hero__day">{{ dayNumber }}</text>
<text class="hero__month">{{ monthLabel }}</text>
<text class="hero__weekday">{{ weekdayLabel }}</text>
</view>
<!-- 分隔虚线 -->
<view class="hero__divider">
<view v-for="i in 6" :key="i" class="hero__divider-dot" />
</view>
<!-- 右侧课程信息 -->
<view class="hero__info">
<view class="hero__time-row">
<text class="hero__time">{{ startTime }}</text>
<text class="hero__time-end"> {{ endTime }}</text>
</view>
<text class="hero__membership">{{ cardName }}</text>
<view class="hero__status">
<view class="hero__dot" />
<text class="hero__status-text">{{ statusLabel }}</text>
<text v-if="countdownText" class="hero__countdown">· {{ countdownText }}</text>
</view>
</view>
</view>
<!-- 底部诗意一句 -->
<view class="hero__footer">
<text class="hero__poem">{{ poem }}</text>
<view class="hero__cta">
<text class="hero__cta-text">查看详情</text>
<text class="hero__cta-arrow"></text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { BookingWithDetails } from '@mp-pilates/shared'
import { BookingStatus } from '@mp-pilates/shared'
import {
bookingStatusLabel,
bookingStatusBannerClass,
} from '../utils/booking-helpers'
const props = defineProps<{
booking: BookingWithDetails
}>()
const emit = defineEmits<{
tap: [booking: BookingWithDetails]
}>()
const months = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十', '十一', '十二']
const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
function parseDate(dateStr: string): Date {
const normalized = dateStr.slice(0, 10)
const [y, m, d] = normalized.split('-').map(Number)
return new Date(y, m - 1, d)
}
const date = computed(() => parseDate(props.booking.timeSlot.date))
const dayNumber = computed(() => String(date.value.getDate()).padStart(2, '0'))
const monthLabel = computed(() => `${date.value.getMonth() + 1}`)
const weekdayLabel = computed(() => weekdays[date.value.getDay()])
const startTime = computed(() => props.booking.timeSlot.startTime.slice(0, 5))
const endTime = computed(() => props.booking.timeSlot.endTime.slice(0, 5))
const cardName = computed(() => props.booking.membership?.cardType?.name || '会员卡')
const statusLabel = computed(() => bookingStatusLabel(props.booking.status))
const tone = computed(() => bookingStatusBannerClass(props.booking.status))
const today = new Date()
const todayStr = computed(() => {
const d = today
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
})
const tomorrow = new Date(today.getTime() + 86400000)
const tomorrowStr = computed(() => {
const d = tomorrow
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
})
const dateKey = computed(() => {
const d = date.value
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
})
const relativeLabel = computed(() => {
if (dateKey.value === todayStr.value) return '今天'
if (dateKey.value === tomorrowStr.value) return '明天'
return `${months[date.value.getMonth()]}${date.value.getDate()}`
})
const countdownText = computed(() => {
if (props.booking.status !== BookingStatus.CONFIRMED) return ''
if (dateKey.value !== todayStr.value && dateKey.value !== tomorrowStr.value) return ''
const target = new Date(`${dateKey.value}T${props.booking.timeSlot.startTime}:00`).getTime()
const diff = target - Date.now()
if (diff <= 0) return '即将开始'
const hours = Math.floor(diff / 3_600_000)
const mins = Math.floor((diff % 3_600_000) / 60_000)
if (dateKey.value === todayStr.value) {
if (hours <= 0) return `${mins} 分钟后`
return `${hours} 小时 ${mins} 分后`
}
return `${hours} 小时 ${mins}`
})
const poem = computed(() => {
const poems: Record<string, string> = {
today: '让呼吸慢一些,让脊柱回到中央。',
tomorrow: '明天见,记得早些休息。',
upcoming: '你的身体,会记得每一次到达。',
pending: '待确认中,我们会为你预留这朵花。',
}
if (dateKey.value === todayStr.value) return poems.today
if (dateKey.value === tomorrowStr.value) return poems.tomorrow
if (props.booking.status === BookingStatus.PENDING_CONFIRMATION) return poems.pending
return poems.upcoming
})
function handleTap() {
emit('tap', props.booking)
}
</script>
<style lang="scss" scoped>
.hero {
position: relative;
margin: 24rpx 32rpx 0;
padding: 36rpx 32rpx 28rpx;
border-radius: 36rpx;
overflow: hidden;
background: linear-gradient(140deg, #efe5d6 0%, #e6d8c4 100%);
color: #3a322b;
box-shadow:
0 1rpx 0 rgba(122, 99, 84, 0.04),
0 12rpx 32rpx rgba(122, 99, 84, 0.06);
&--pending {
background: linear-gradient(140deg, #ece1cd 0%, #e2d2b8 100%);
}
&--confirmed {
background: linear-gradient(140deg, #e2ebe2 0%, #d4e0d3 100%);
}
&--completed {
background: linear-gradient(140deg, #efe5d6 0%, #e6d8c4 100%);
}
&--cancelled {
background: linear-gradient(140deg, #ede4dc 0%, #e3d6c9 100%);
opacity: 0.85;
}
&--noshow {
background: linear-gradient(140deg, #ede0dc 0%, #e3cfc7 100%);
}
&__grain {
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0.4;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='200' height='200'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0.45 0 0 0 0 0.36 0 0 0 0 0.27 0 0 0 0.18 0'/></filter><rect width='200' height='200' filter='url(%23n)'/></svg>");
mix-blend-mode: multiply;
}
&__moon {
position: absolute;
top: -80rpx;
right: -60rpx;
width: 220rpx;
height: 220rpx;
border-radius: 50%;
background: radial-gradient(circle at 30% 30%, rgba(255, 248, 235, 0.55) 0%, rgba(255, 248, 235, 0) 70%);
pointer-events: none;
}
&__main {
position: relative;
display: flex;
align-items: stretch;
gap: 24rpx;
}
&__date {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2rpx;
width: 132rpx;
flex-shrink: 0;
padding: 8rpx 0;
border-radius: 20rpx;
background: rgba(255, 251, 244, 0.5);
backdrop-filter: blur(8rpx);
}
&__day {
font-family: "Songti SC", "STSong", "Noto Serif SC", "Source Han Serif SC", serif;
font-size: 72rpx;
line-height: 1;
font-weight: 500;
color: #3a322b;
font-variant-numeric: tabular-nums;
}
&__month {
margin-top: 8rpx;
font-size: 22rpx;
color: #6f645a;
letter-spacing: 2rpx;
}
&__weekday {
font-size: 20rpx;
color: #8b7d70;
letter-spacing: 1rpx;
}
&__divider {
display: flex;
flex-direction: column;
justify-content: center;
gap: 6rpx;
flex-shrink: 0;
width: 2rpx;
padding: 8rpx 0;
}
&__divider-dot {
width: 2rpx;
height: 8rpx;
background: #b09a83;
border-radius: 1rpx;
opacity: 0.5;
}
&__info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 8rpx;
padding: 4rpx 0;
}
&__time-row {
display: flex;
align-items: baseline;
gap: 6rpx;
}
&__time {
font-family: "Songti SC", "STSong", "Noto Serif SC", "Source Han Serif SC", serif;
font-size: 50rpx;
line-height: 1;
font-weight: 500;
color: #2a2520;
font-variant-numeric: tabular-nums;
letter-spacing: -1rpx;
}
&__time-end {
font-size: 24rpx;
color: #6f645a;
font-variant-numeric: tabular-nums;
}
&__membership {
font-size: 24rpx;
color: #6f645a;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__status {
display: flex;
align-items: center;
gap: 8rpx;
margin-top: 4rpx;
}
&__dot {
width: 10rpx;
height: 10rpx;
border-radius: 50%;
background: #6e8b7d;
box-shadow: 0 0 0 4rpx rgba(110, 139, 125, 0.18);
}
&__status-text {
font-size: 22rpx;
color: #6e8b7d;
letter-spacing: 1rpx;
}
&__countdown {
font-size: 22rpx;
color: #8b7d70;
font-variant-numeric: tabular-nums;
}
&__footer {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
margin-top: 28rpx;
padding-top: 20rpx;
border-top: 1rpx dashed rgba(122, 99, 84, 0.22);
}
&__poem {
flex: 1;
font-size: 22rpx;
color: #6f645a;
letter-spacing: 2rpx;
font-style: italic;
line-height: 1.5;
}
&__cta {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 6rpx;
padding: 6rpx 0;
}
&__cta-text {
font-size: 22rpx;
color: #8b7d70;
letter-spacing: 2rpx;
}
&__cta-arrow {
font-size: 22rpx;
color: #8b7d70;
transform: translateY(-1rpx);
transition: transform 0.3s ease;
}
}
.hero:active {
transform: scale(0.99);
transition: transform 0.15s ease;
.hero__cta-arrow {
transform: translate(4rpx, -1rpx);
}
}
</style>

View File

@@ -0,0 +1,126 @@
<template>
<view class="practice">
<view class="practice__heading">
<view>
<view class="practice__title">练习足迹<text class="practice__period">最近 30 </text></view>
</view>
<view class="practice__summary"><text class="practice__number">{{ activity ? total : '—' }}</text><text>节课</text></view>
</view>
<view v-if="error" class="practice__state">
<text>暂时未能加载练习记录</text>
<button class="practice__retry" @tap="load">重新加载</button>
</view>
<template v-else>
<view class="practice__range">
<text>{{ activity ? formatDate(activity.days[0].date) : '正在读取练习记录' }}</text>
<text>{{ activity ? formatDate(activity.days[29].date) + ' · 今天' : '' }}</text>
</view>
<view class="practice__grid" :class="{ 'practice__grid--loading': !activity }">
<button v-for="(day, index) in cells" :key="day.date || index"
class="practice__cell" :class="[
`practice__cell--${Math.min(day.count, 3)}`,
{ 'practice__cell--selected': selected === day.date && !!activity, 'practice__cell--today': index === 29 },
]"
:disabled="!activity" :aria-label="activity ? `${formatDate(day.date)}已完成 ${day.count} 节课` : '加载中'"
@tap="selected = day.date">
<text>{{ day.date ? Number(day.date.slice(-2)) : '' }}</text>
</button>
</view>
<view class="practice__footer">
<text class="practice__detail">{{ detail }}</text>
<view class="practice__legend">
<text>0</text><view class="practice__swatch practice__cell--0" />
<view class="practice__swatch practice__cell--1" /><view class="practice__swatch practice__cell--2" />
<view class="practice__swatch practice__cell--3" /><text>3+ </text>
</view>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import type { PracticeActivity } from '@mp-pilates/shared'
import { get } from '../utils/request'
const props = defineProps<{ refreshKey: number }>()
const activity = ref<PracticeActivity | null>(null)
const selected = ref('')
const error = ref(false)
let loading = false
let disposed = false
const cells = computed(() => activity.value?.days ?? Array.from({ length: 30 }, () => ({ date: '', count: 0 })))
const total = computed(() => cells.value.reduce((sum, day) => sum + day.count, 0))
const activeDays = computed(() => cells.value.filter(day => day.count > 0).length)
const detail = computed(() => {
if (!activity.value) return '正在整理练习记录'
const day = activity.value.days.find(item => item.date === selected.value)
if (day) return `${formatDate(day.date)} · ${day.count ? `已完成 ${day.count} 节课` : '暂无已完成课程'}`
return total.value ? `已练习 ${activeDays.value} 天 · 点选查看` : '近 30 天暂无已完成课程'
})
function formatDate(date: string) {
return `${Number(date.slice(5, 7))}${Number(date.slice(8, 10))}`
}
async function load() {
if (loading || disposed) return
loading = true
error.value = false
try {
const result = await get<PracticeActivity>('/booking/my/activity')
if (!disposed) {
activity.value = result
if (!result.days.some(day => day.date === selected.value)) selected.value = ''
}
} catch {
if (!disposed) {
activity.value = null
error.value = true
}
} finally {
loading = false
}
}
onMounted(load)
watch(() => props.refreshKey, load)
onUnmounted(() => { disposed = true })
</script>
<style scoped lang="scss">
.practice {
margin: 24rpx 32rpx 0;
padding: 28rpx;
border: 1rpx solid #eee8e3;
border-radius: 28rpx;
background: #fff;
color: #514943;
&__heading, &__range, &__footer, &__legend { display: flex; align-items: center; justify-content: space-between; }
&__title { font-size: 28rpx; font-weight: 500; }
&__period { margin-left: 16rpx; font-size: 21rpx; font-weight: 400; letter-spacing: 0; color: #8b817b; }
&__summary { display: flex; align-items: baseline; gap: 8rpx; font-size: 21rpx; color: #8b817b; }
&__number { font-size: 44rpx; font-weight: 400; line-height: 1; color: #617d73; font-variant-numeric: tabular-nums; }
&__range { margin: 24rpx 0 12rpx; font-size: 20rpx; color: #8b817b; }
&__grid { display: grid; grid-template-columns: repeat(10, minmax(0, 1fr)); gap: 9rpx; }
&__cell {
width: 100%; height: 44rpx; min-width: 0; margin: 0; padding: 0;
display: flex; justify-content: center; align-items: center;
border-radius: 8rpx; border: 2rpx solid transparent; box-sizing: border-box;
font-size: 19rpx; line-height: 1; font-variant-numeric: tabular-nums;
&::after { border: none; }
&--0 { background: #edf0e9; color: #7c8676; }
&--1 { background: #ccd9c6; color: #516d50; }
&--2 { background: #9cb594; color: #304b36; }
&--3 { background: #637f5f; color: #ffffff; }
&--today { border-bottom-color: #516d50; }
&--selected { box-shadow: 0 0 0 3rpx #fff, 0 0 0 5rpx #637f5f; }
}
&__grid--loading { opacity: 0.5; }
&__footer { margin-top: 22rpx; gap: 12rpx; flex-wrap: wrap; }
&__detail { font-size: 21rpx; color: #788477; }
&__legend { gap: 5rpx; font-size: 18rpx; color: #8b817b; }
&__swatch { width: 13rpx; height: 13rpx; border-radius: 3rpx; }
&__state { min-height: 200rpx; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 20rpx; font-size: 24rpx; color: #8b817b; }
&__retry { margin: 0; padding: 0 24rpx; line-height: 56rpx; font-size: 24rpx; color: #516d50; background: #edf0e9; border-radius: 28rpx; &::after { border: none; } }
}
</style>

View File

@@ -1,28 +1,29 @@
<template>
<view class="profile-menu">
<template v-for="item in menuItems" :key="item.key">
<!-- Separator -->
<view v-if="item.type === 'separator'" class="profile-menu__separator" />
<!-- Menu item -->
<view
v-else
class="profile-menu__item"
:class="{ 'profile-menu__item--admin': item.isAdmin }"
hover-class="profile-menu__item--hover"
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 class="profile-menu__shortcuts">
<view v-for="item in primaryItems" :key="item.key" class="profile-menu__shortcut"
hover-class="profile-menu__item--hover" @tap="handleTap(item)">
<view class="profile-menu__shortcut-head">
<view class="profile-menu__icon" :class="'profile-menu__icon--' + item.key" />
<text class="profile-menu__arrow"></text>
</view>
<text class="profile-menu__title" :class="{ 'profile-menu__title--admin': item.isAdmin }">
{{ item.title }}
</text>
<text v-if="item.badge" class="profile-menu__badge">{{ item.badge }}</text>
<text class="profile-menu__arrow"></text>
<text class="profile-menu__shortcut-title">{{ item.title }}</text>
<text class="profile-menu__shortcut-note">{{ !requireAuth ? '登录后查看' : item.key === 'membership' ? `${activeMembershipCount || 0} 张可用` : `${upcomingBookingCount || 0} 节待上` }}</text>
</view>
</template>
</view>
<slot />
<view class="profile-menu__links">
<template v-for="item in secondaryItems" :key="item.key">
<view v-if="item.type === 'separator'" class="profile-menu__separator" />
<view v-else class="profile-menu__item" :class="{ 'profile-menu__item--admin': item.isAdmin }"
hover-class="profile-menu__item--hover" hover-stay-time="150" @tap="handleTap(item)">
<text class="profile-menu__title">{{ item.title }}</text>
<text class="profile-menu__arrow"></text>
</view>
</template>
</view>
</view>
</template>
@@ -32,48 +33,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 +112,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 +122,6 @@ const menuItems = computed<MenuItem[]>(() => {
items.push({
key: 'admin',
type: 'item',
icon: '⚙️',
title: '管理中心',
path: '/pages/admin/index',
isAdmin: true,
@@ -114,6 +132,9 @@ const menuItems = computed<MenuItem[]>(() => {
return items
})
const primaryItems = computed(() => menuItems.value.filter(item => item.key === 'membership' || item.key === 'bookings'))
const secondaryItems = computed(() => menuItems.value.filter(item => item.key !== 'membership' && item.key !== 'bookings'))
function handleTap(item: MenuItem) {
if (item.requireAuth && !props.requireAuth) {
emit('require-login')
@@ -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 })
}
@@ -131,84 +150,20 @@ function handleTap(item: MenuItem) {
<style lang="scss" scoped>
.profile-menu {
background: $bg-card;
border-radius: $radius-lg;
margin: $spacing-lg $spacing-lg 0;
overflow: hidden;
&__separator {
height: $spacing-sm;
background: $bg-page;
}
&__item {
display: flex;
align-items: center;
padding: $spacing-md $spacing-lg;
border-bottom: 1rpx solid $border-color;
background: $bg-card;
transition: background 0.15s;
&:last-child {
border-bottom: none;
}
&--hover {
background: #f5f5f5;
}
&--admin {
background: rgba($accent-color, 0.04);
}
}
&__icon-wrap {
width: 64rpx;
height: 64rpx;
border-radius: $radius-sm;
background: rgba($brand-color, 0.08);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-right: $spacing-md;
&--admin {
background: rgba($accent-color, 0.12);
}
}
&__icon {
font-size: 32rpx;
line-height: 1;
}
&__title {
flex: 1;
font-size: 30rpx;
color: $text-primary;
&--admin {
color: $accent-color;
font-weight: 500;
}
}
&__badge {
font-size: 22rpx;
color: #ffffff;
background: $error-color;
border-radius: 20rpx;
padding: 2rpx 12rpx;
margin-right: $spacing-sm;
}
&__arrow {
font-size: 36rpx;
color: $text-hint;
line-height: 1;
transform: scaleX(0.6);
transform-origin: center;
}
&__shortcuts { display: flex; gap: 20rpx; margin: 24rpx 32rpx 0; }
&__shortcut { flex: 1; min-width: 0; padding: 24rpx; box-sizing: border-box; border-radius: 26rpx; background: #fff; border: 1rpx solid #eee8e3; }
&__shortcut-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16rpx; }
&__shortcut-title { display: block; font-size: 27rpx; font-weight: 500; color: #514943; }
&__shortcut-note { display: block; font-size: 22rpx; color: #8b817b; margin-top: 8rpx; }
&__icon { width: 36rpx; height: 30rpx; position: relative; box-sizing: border-box; border: 2rpx solid #9b8878; border-radius: 6rpx; }
&__icon--membership::after { content: ''; position: absolute; top: 8rpx; left: 0; right: 0; border-top: 2rpx solid #9b8878; }
&__icon--bookings { border-color: #829a8b; border-top-width: 7rpx; &::after { content: ''; position: absolute; left: 8rpx; top: 8rpx; width: 12rpx; border-top: 2rpx solid #829a8b; } }
&__links { margin: 24rpx 32rpx 0; border-radius: 26rpx; background: #fff; overflow: hidden; }
&__item { display: flex; align-items: center; gap: 20rpx; min-height: 96rpx; padding: 0 28rpx; box-sizing: border-box; border-bottom: 1rpx solid #f2ede8; &:last-child { border-bottom: none; } }
&__item--hover { background: #f4f1ec; }
&__item--admin { background: #eff3ed; .profile-menu__title { color: #617d73; } }
&__title { flex: 1; font-size: 27rpx; font-weight: 400; color: #6f655e; }
&__arrow { font-size: 30rpx; color: #b2a79e; line-height: 1; }
&__separator { height: 12rpx; background: #fbf9f6; }
}
</style>

View File

@@ -1,88 +1,51 @@
<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">
<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>
<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">
<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="renewStripText" class="renew-strip" @tap="handleRenew">
<text class="renew-strip-text">{{ renewStripText }}</text>
<text class="renew-strip-arrow"></text>
</view>
</template>
<!-- Has memberships but none active buy card -->
<view
v-else
class="entry-card expired-card"
@tap="scrollToCardShop"
class="entry-pill pill-expired"
@tap="handleRenew"
>
<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>
<text class="pill-label">暂无可用会员卡</text>
<view class="pill-action action-renew">
<text class="pill-action-text">续卡</text>
</view>
</view>
</view>
@@ -91,7 +54,7 @@
<script setup lang="ts">
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 +67,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,193 +87,55 @@ function scrollToCardShop() {
emit('scroll-to-card-shop')
}
// Computed: label for the active membership
function handleRenew() {
const cardTypeId = userStore.renewalHint?.cardTypeId
if (cardTypeId) {
uni.navigateTo({ url: `/pages/card/detail?id=${cardTypeId}` })
return
}
scrollToCardShop()
}
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}`
if (m.remainingTimes !== null) {
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) =>
m.cardType.type === CardTypeCategory.TIMES &&
m.remainingTimes !== null &&
m.remainingTimes <= 2,
)
})
const lowestRemainingTimes = computed(() => {
const timesCards = userStore.activeMemberships.filter(
(m) =>
m.cardType.type === CardTypeCategory.TIMES &&
m.remainingTimes !== null &&
m.remainingTimes <= 2,
)
if (!timesCards.length) return 0
return Math.min(...timesCards.map((m) => m.remainingTimes as number))
const renewStripText = computed(() => {
const hint = userStore.renewalHint
if (!hint || !userStore.hasValidMembership) return ''
if (hint.kind === 'times_low') {
return `仅剩 ${hint.remainingTimes ?? 0} 次 · 续卡保持节奏`
}
if (hint.kind === 'days_low') {
return `还剩 ${hint.daysLeft ?? 0} 天到期 · 续卡不中断`
}
if (hint.kind === 'trial_low') {
return '体验课将尽 · 选购会员卡'
}
return ''
})
</script>
<style lang="scss" scoped>
.quick-entry {
margin: 24rpx 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 {
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;
border-radius: 40rpx;
display: flex;
align-items: center;
justify-content: center;
}
.entry-btn-text {
font-size: 28rpx;
font-weight: 600;
white-space: nowrap;
}
.login-btn {
background: #c9a87c;
}
.trial-btn {
background: #c9a87c;
}
.book-btn {
background: #c9a87c;
}
.renew-btn {
background: #888;
}
.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 {
display: flex;
align-items: center;
gap: 12rpx;
margin-top: 16rpx;
padding: 20rpx 24rpx;
background: #fff8f0;
border-radius: 12rpx;
border: 1rpx solid #f0d9bc;
}
.renew-tip-icon {
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;
}
.quick-entry { margin: 24rpx 32rpx 0; }
.entry-pill { display: flex; align-items: center; min-height: 120rpx; box-sizing: border-box; padding: 24rpx; gap: 20rpx; border-radius: 28rpx; background: #f1e8e1; }
.pill-active { background: #edf2ec; }
.pill-expired { background: #f0ece7; }
.pill-label { flex: 1; min-width: 0; font-size: 26rpx; line-height: 1.6; color: #6f5c50; overflow-wrap: anywhere; }
.pill-label-active { color: #526b5d; }
.pill-action { flex-shrink: 0; min-height: 68rpx; padding: 0 24rpx; border-radius: 999rpx; display: flex; align-items: center; justify-content: center; background: #6b8276; }
.pill-action-text { font-size: 24rpx; font-weight: 400; color: #fff; white-space: nowrap; }
.renew-strip { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; padding: 18rpx 12rpx 0; }
.renew-strip-text { font-size: 22rpx; color: #8b817b; line-height: 1.5; }
.renew-strip-arrow { font-size: 28rpx; color: #8b817b; }
</style>

View File

@@ -0,0 +1,288 @@
<template>
<view
class="session"
:class="[
`session--${tone}`,
{ 'session--muted': muted, 'session--history': history },
]"
@tap="handleTap"
>
<!-- 左侧日期块 -->
<view class="session__date">
<text class="session__day">{{ dayNumber }}</text>
<text class="session__weekday">{{ weekdayLabel }}</text>
</view>
<!-- 中央分隔虚线 -->
<view class="session__rail">
<view class="session__dot" />
<view class="session__line" />
</view>
<!-- 主体信息 -->
<view class="session__body">
<view class="session__top">
<text class="session__time">{{ startTime }}</text>
<text class="session__time-end"> {{ endTime }}</text>
</view>
<text class="session__membership">{{ cardName }}</text>
<view class="session__bottom">
<view class="session__status">
<view class="session__status-dot" />
<text class="session__status-text">{{ statusLabel }}</text>
</view>
<text v-if="!history" class="session__cancel" @tap.stop="handleCancel">取消预约</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { BookingWithDetails } from '@mp-pilates/shared'
import { BookingStatus } from '@mp-pilates/shared'
import {
bookingStatusLabel,
bookingStatusStripeClass,
} from '../utils/booking-helpers'
const props = defineProps<{
booking: BookingWithDetails
/** 历史记录模式(更紧凑,隐藏取消按钮) */
history?: boolean
}>()
const emit = defineEmits<{
tap: [booking: BookingWithDetails]
cancel: [booking: BookingWithDetails]
}>()
const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
function parseDate(dateStr: string): Date {
const normalized = dateStr.slice(0, 10)
const [y, m, d] = normalized.split('-').map(Number)
return new Date(y, m - 1, d)
}
const date = computed(() => parseDate(props.booking.timeSlot.date))
const dayNumber = computed(() => {
const d = date.value.getDate()
return d < 10 ? `0${d}` : String(d)
})
const weekdayLabel = computed(() => weekdays[date.value.getDay()])
const startTime = computed(() => props.booking.timeSlot.startTime.slice(0, 5))
const endTime = computed(() => props.booking.timeSlot.endTime.slice(0, 5))
const cardName = computed(() => props.booking.membership?.cardType?.name || '会员卡')
const statusLabel = computed(() => bookingStatusLabel(props.booking.status))
const tone = computed(() => bookingStatusStripeClass(props.booking.status))
const muted = computed(() => {
return props.booking.status === BookingStatus.CANCELLED || props.booking.status === BookingStatus.NO_SHOW
})
function handleTap() {
emit('tap', props.booking)
}
function handleCancel() {
emit('cancel', props.booking)
}
</script>
<style lang="scss" scoped>
.session {
position: relative;
display: flex;
align-items: stretch;
gap: 20rpx;
padding: 28rpx 24rpx;
margin: 0 32rpx 16rpx;
border-radius: 28rpx;
background: #fdfbf7;
border: 1rpx solid #ede5d8;
box-shadow:
0 1rpx 0 rgba(122, 99, 84, 0.02),
0 4rpx 16rpx rgba(122, 99, 84, 0.03);
transition: transform 0.2s ease, box-shadow 0.2s ease;
&__date {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2rpx;
width: 80rpx;
flex-shrink: 0;
padding: 4rpx 0;
}
&__day {
font-family: "Songti SC", "STSong", "Noto Serif SC", "Source Han Serif SC", serif;
font-size: 44rpx;
font-weight: 500;
line-height: 1;
color: #3a322b;
font-variant-numeric: tabular-nums;
}
&__weekday {
font-size: 19rpx;
color: #a89d92;
letter-spacing: 1rpx;
margin-top: 4rpx;
}
&__rail {
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
width: 14rpx;
flex-shrink: 0;
padding: 6rpx 0;
}
&__dot {
width: 10rpx;
height: 10rpx;
border-radius: 50%;
background: #6e8b7d;
flex-shrink: 0;
}
&__line {
width: 1rpx;
flex: 1;
background: linear-gradient(
to bottom,
rgba(155, 138, 117, 0.3) 0%,
rgba(155, 138, 117, 0) 100%
);
}
// 状态颜色
&--stripe--pending &__dot { background: #b8967c; }
&--stripe--confirmed &__dot { background: #6e8b7d; }
&--stripe--completed &__dot { background: #6e8b7d; }
&--stripe--cancelled &__dot { background: #c4a09a; }
&--stripe--noshow &__dot { background: #b89a93; }
&__body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6rpx;
padding: 4rpx 0;
}
&__top {
display: flex;
align-items: baseline;
gap: 6rpx;
}
&__time {
font-family: "Songti SC", "STSong", "Noto Serif SC", "Source Han Serif SC", serif;
font-size: 34rpx;
font-weight: 500;
color: #3a322b;
font-variant-numeric: tabular-nums;
}
&__time-end {
font-size: 22rpx;
color: #a89d92;
font-variant-numeric: tabular-nums;
}
&__membership {
font-size: 22rpx;
color: #786b61;
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__bottom {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12rpx;
margin-top: 6rpx;
}
&__status {
display: flex;
align-items: center;
gap: 8rpx;
}
&__status-dot {
width: 8rpx;
height: 8rpx;
border-radius: 50%;
background: #6e8b7d;
}
&__status-text {
font-size: 21rpx;
color: #786b61;
letter-spacing: 1rpx;
}
&--stripe--pending &__status-text { color: #957c65; }
&--stripe--pending &__status-dot { background: #b8967c; }
&--stripe--cancelled &__status-text { color: #a89d92; }
&--stripe--cancelled &__status-dot { background: #c4a09a; }
&--stripe--noshow &__status-text { color: #9c7a6e; }
&--stripe--noshow &__status-dot { background: #b89a93; }
&__cancel {
font-size: 22rpx;
color: #a89d92;
padding: 6rpx 0 6rpx 16rpx;
letter-spacing: 1rpx;
transition: color 0.2s ease;
}
&__cancel:active {
color: #9c7a6e;
}
// 已取消 / 未出席:弱化
&--muted {
background: #f5f0e8;
border-color: #e8e0d4;
.session__day {
color: #a89d92;
}
.session__time {
color: #a89d92;
}
.session__membership {
color: #a89d92;
}
.session__dot {
opacity: 0.5;
}
}
}
.session:active {
transform: scale(0.99);
box-shadow:
0 1rpx 0 rgba(122, 99, 84, 0.02),
0 2rpx 8rpx rgba(122, 99, 84, 0.04);
}
</style>

View File

@@ -1,228 +1,120 @@
<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>
<view class="slot-card" :class="`slot-card--${statusClass}`" @tap="emit('cardTap', timeSlot)">
<view class="slot-heading">
<view class="slot-time">
<text class="time-start">{{ startTimeDisplay }}</text>
<text class="time-end"> {{ endTimeDisplay }}</text>
</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>
<!-- OPEN + booked by me -->
<template v-else-if="slot.status === TimeSlotStatus.OPEN && slot.isBookedByMe">
<view class="booked-row">
<view class="badge-booked">
<text class="badge-text">已预约</text>
</view>
<view class="btn-cancel" @tap.stop="emit('cancel', slot)">
<text class="btn-cancel-text">取消</text>
</view>
</view>
</template>
<!-- FULL -->
<template v-else-if="slot.status === TimeSlotStatus.FULL">
<view class="btn btn-disabled">
<text class="btn-text">已约满</text>
</view>
</template>
<!-- CLOSED -->
<template v-else>
<view class="btn btn-disabled">
<text class="btn-text">已关闭</text>
<text class="slot-duration">{{ durationMin }} 分钟</text>
</view>
<view class="slot-body">
<view class="course-info">
<text class="course-name">普拉提私教</text>
<text class="slot-status">{{ capacityLabel }}</text>
</view>
<view class="action-area">
<template v-if="timeSlot.isBookedByMe">
<text class="booked-label">已预约</text>
<view v-if="timeSlot.status === TimeSlotStatus.OPEN" class="cancel-link" @tap.stop="emit('cancel', timeSlot)">
<text>取消预约</text>
</view>
</template>
<button v-else-if="!isPast && timeSlot.status === TimeSlotStatus.OPEN && timeSlot.bookedCount < timeSlot.capacity"
class="book-button" hover-class="book-button--pressed" @tap.stop="emit('book', timeSlot)">
预约
</button>
<text v-else class="unavailable-label">{{ isPast ? '已结束' : timeSlot.status === TimeSlotStatus.CLOSED ? '未开放' : '已约满' }}</text>
</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 capacityLabel = computed(() => {
const { bookedCount, capacity, status } = props.slot
if (status === TimeSlotStatus.CLOSED) return '已关闭'
return `${bookedCount}/${capacity}`
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 capacityClass = computed(() => {
const { bookedCount, capacity, status } = props.slot
if (status === TimeSlotStatus.CLOSED) return 'cap-closed'
if (status === TimeSlotStatus.FULL) return 'cap-full'
if (bookedCount >= capacity * 0.8) return 'cap-almost'
return 'cap-open'
const myBookingLabel = computed(() => (
props.timeSlot.myBookingStatus === BookingStatus.PENDING_CONFIRMATION
? '待老师确认'
: '已预约'
))
const capacityLabel = computed(() => {
const { bookedCount, capacity, status, isBookedByMe } = props.timeSlot
if (isBookedByMe) return myBookingLabel.value
if (isPast.value) return '已结束'
if (status === TimeSlotStatus.CLOSED) return '暂未开放'
if (status === TimeSlotStatus.FULL || bookedCount >= capacity) return '名额已满'
return `还可约 ${Math.max(0, capacity - bookedCount)}`
})
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 {
margin: 0 32rpx 20rpx;
padding: 28rpx;
border: 1rpx solid #eee8e3;
border-radius: 28rpx;
background: #fff;
border-radius: 20rpx;
overflow: hidden;
box-shadow: 0 2rpx 16rpx rgba(0, 0, 0, 0.06);
position: relative;
.booked-bar {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 6rpx;
background: #c9a87c;
border-radius: 20rpx 0 0 20rpx;
}
.slot-main {
display: flex;
flex-direction: row;
align-items: center;
padding: 32rpx 28rpx 32rpx 36rpx;
gap: 20rpx;
}
.slot-time-block {
flex: 1;
display: flex;
flex-direction: column;
gap: 12rpx;
}
.slot-time {
font-size: 36rpx;
font-weight: 700;
color: #1a1a1a;
letter-spacing: 1rpx;
}
.slot-capacity {
display: inline-flex;
align-self: flex-start;
.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;
}
}
.slot-action {
flex-shrink: 0;
}
.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;
}
}
}
.booked-row {
display: flex;
flex-direction: row;
align-items: center;
gap: 16rpx;
}
.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;
}
}
.btn-cancel {
height: 52rpx;
padding: 0 16rpx;
display: flex;
align-items: center;
.btn-cancel-text {
font-size: 24rpx;
color: #ef4444;
font-weight: 500;
text-decoration: underline;
}
}
color: #514943;
}
.slot-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 16rpx; }
.slot-time { display: flex; align-items: baseline; gap: 12rpx; font-variant-numeric: tabular-nums; }
.time-start { font-size: 40rpx; font-weight: 500; line-height: 1.2; }
.time-end { font-size: 28rpx; color: #8b817b; }
.slot-duration { flex-shrink: 0; font-size: 22rpx; color: #8b817b; }
.slot-body { display: flex; align-items: center; justify-content: space-between; gap: 20rpx; margin-top: 24rpx; }
.course-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
.course-name { font-size: 26rpx; font-weight: 400; }
.slot-status { font-size: 22rpx; color: #617d73; }
.action-area { display: flex; flex-direction: column; align-items: flex-end; gap: 2rpx; flex-shrink: 0; }
.book-button {
display: flex; align-items: center; justify-content: center;
min-width: 136rpx; height: 72rpx; margin: 0; padding: 0 30rpx;
border: none; border-radius: 999rpx; background: #6b8276;
font-size: 26rpx; font-weight: 400; line-height: 1; color: #fff;
&::after { border: none; }
&--pressed { background: #597264; }
}
.booked-label { font-size: 24rpx; color: #617d73; }
.cancel-link { padding: 12rpx 0 8rpx 20rpx; font-size: 21rpx; color: #8b817b; }
.unavailable-label { padding: 14rpx 24rpx; border-radius: 999rpx; background: #f3f0ed; color: #8b817b; font-size: 23rpx; }
.slot-card--booked { background: #f2f5f0; border-color: #e2e9df; }
.slot-card--expired, .slot-card--closed, .slot-card--full {
background: #f7f5f2;
.time-start, .course-name { color: #8b817b; }
.slot-status { color: #948a82; }
}
</style>

View File

@@ -1,61 +1,77 @@
<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 -->
<text class="section-title">来工作室坐坐</text>
<!-- 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-content">
<text class="location-label">场馆地址</text>
<text class="location-text">
{{ studioInfo?.address || '深圳市宝安区西乡街道财富港 D 座 1203D' }}
</text>
</view>
</view>
<button class="chat-btn" open-type="contact">
<text>联系老师</text>
</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>
</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,87 +84,18 @@ 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 {
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;
}
/* ── Location row ── */
.location-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 32rpx 28rpx;
gap: 24rpx;
}
.location-left {
display: flex;
align-items: center;
gap: 12rpx;
flex: 1;
min-width: 0;
}
.location-icon {
font-size: 28rpx;
flex-shrink: 0;
}
.location-text {
font-size: 26rpx;
color: #666;
line-height: 1.5;
word-break: break-all;
}
.phone-btn {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.phone-icon {
font-size: 36rpx;
color: #4CAF50;
}
.studio-info { margin: 36rpx 32rpx 0; }
.section-title { display: block; margin-bottom: 20rpx; font-size: 30rpx; font-weight: 500; color: #514943; }
.location-row { display: flex; align-items: center; justify-content: space-between; gap: 20rpx; margin-bottom: 20rpx; }
.location-left, .location-content { flex: 1; min-width: 0; }
.location-label { display: block; font-size: 21rpx; color: #8b817b; margin-bottom: 6rpx; }
.location-text { font-size: 24rpx; color: #6f655e; line-height: 1.7; overflow-wrap: anywhere; }
.chat-btn { flex-shrink: 0; margin: 0; padding: 0 20rpx; line-height: 64rpx; font-size: 23rpx; border: none; border-radius: 999rpx; background: #f0eae4; color: #78675c; &::after { border: none; } }
.gallery-scroll { width: 100%; }
.gallery-track { display: inline-flex; gap: 16rpx; }
.gallery-item { width: 264rpx; height: 180rpx; border-radius: 20rpx; overflow: hidden; flex-shrink: 0; background: #e9e1d8; }
.gallery-image { width: 100%; height: 100%; }
</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' | 'soft'
}
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,44 @@ 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);
}
}
}
}
}
.time-period-filter.time-period-filter--soft {
margin: 0 32rpx 24rpx; padding: 6rpx;
border: none; border-radius: 999rpx; background: #f0ece7;
.tab-item {
padding: 14rpx 0; border-radius: 999rpx;
.tab-label { font-size: 24rpx; color: #8b817b; }
&.active {
background: #fff;
.tab-label { color: #617d73; font-weight: 500; }
&::after { display: none; }
}
}
}
</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">
@@ -24,7 +25,6 @@
<view class="details-col">
<text class="card-name">{{ booking.membership.cardType.name }}</text>
<view class="time-row">
<text class="time-icon">🕐</text>
<text class="time-text">
{{ formatTime(booking.timeSlot.startTime) }} {{ formatTime(booking.timeSlot.endTime) }}
</text>
@@ -72,6 +72,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 +82,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 +90,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,133 +100,32 @@ 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>
.upcoming-section {
margin: 24rpx 24rpx 0;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
}
.section-title {
font-size: 32rpx;
font-weight: 700;
color: #1a1a2e;
}
.section-more {
font-size: 26rpx;
color: #c9a87c;
}
.booking-card {
background: #ffffff;
border-radius: 16rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.08);
padding: 28rpx 28rpx;
display: flex;
align-items: center;
gap: 24rpx;
margin-bottom: 16rpx;
}
.date-col {
display: flex;
flex-direction: column;
align-items: center;
flex-shrink: 0;
width: 88rpx;
}
.date-day {
font-size: 52rpx;
font-weight: 800;
color: #1a1a2e;
line-height: 1;
}
.date-month {
font-size: 22rpx;
color: #c9a87c;
margin-top: 4rpx;
}
.date-weekday {
font-size: 22rpx;
color: #999;
margin-top: 4rpx;
}
.booking-divider {
width: 2rpx;
height: 80rpx;
background: #f0f0f0;
flex-shrink: 0;
}
.details-col {
flex: 1;
}
.card-name {
display: block;
font-size: 30rpx;
font-weight: 600;
color: #1a1a2e;
margin-bottom: 10rpx;
}
.time-row {
display: flex;
align-items: center;
gap: 8rpx;
margin-bottom: 10rpx;
}
.time-icon {
font-size: 24rpx;
}
.time-text {
font-size: 26rpx;
color: #555;
}
.status-row {
display: flex;
align-items: center;
gap: 8rpx;
}
.status-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
}
.dot--confirmed { background: #27ae60; }
.dot--completed { background: #3498db; }
.dot--cancelled { background: #e74c3c; }
.dot--default { background: #999; }
.status-text {
font-size: 24rpx;
color: #999;
}
.text--confirmed { color: #27ae60; }
.text--completed { color: #3498db; }
.text--cancelled { color: #e74c3c; }
.booking-arrow {
font-size: 36rpx;
color: #ccc;
flex-shrink: 0;
}
.upcoming-section { margin: 36rpx 32rpx 0; }
.section-header { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; margin-bottom: 20rpx; }
.section-title { font-size: 30rpx; font-weight: 500; color: #514943; }
.section-more { font-size: 23rpx; color: #8b817b; padding: 8rpx 0; }
.booking-card { display: flex; align-items: center; gap: 24rpx; margin-bottom: 16rpx; padding: 26rpx; border-radius: 28rpx; background: #fff; border: 1rpx solid #eee8e3; }
.date-col { display: flex; flex-direction: column; align-items: center; gap: 4rpx; width: 72rpx; flex-shrink: 0; }
.date-day { font-size: 44rpx; font-weight: 400; line-height: 1.1; color: #617d73; }
.date-month, .date-weekday { font-size: 20rpx; color: #8b817b; }
.booking-divider { width: 1rpx; height: 80rpx; background: #eee8e3; flex-shrink: 0; }
.details-col { flex: 1; min-width: 0; }
.card-name { display: block; font-size: 26rpx; color: #514943; font-weight: 400; line-height: 1.5; margin-bottom: 8rpx; overflow-wrap: anywhere; }
.time-row { margin-bottom: 8rpx; }
.time-text { font-size: 24rpx; color: #6f655e; font-variant-numeric: tabular-nums; }
.status-row { display: flex; align-items: center; gap: 8rpx; }
.status-dot { width: 8rpx; height: 8rpx; border-radius: 50%; background: #a5b8ab; }
.status-text { font-size: 21rpx; color: #617d73; }
.dot--pending { background: #b8a18b; }
.text--pending { color: #957c65; }
.dot--cancelled { background: #b69589; }
.text--cancelled { color: #9c7a6e; }
.booking-arrow { font-size: 32rpx; color: #b2a79e; flex-shrink: 0; }
</style>

View File

@@ -1,15 +1,14 @@
<template>
<view class="user-card">
<!-- Header: gradient background -->
<view class="user-card__header">
<!-- 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>
<text class="user-card__guest-sub">登录后查看个人数据</text>
<text class="user-card__guest-title">欢迎来到工作室</text>
<text class="user-card__guest-sub">登录后查看会员卡与练习记录</text>
</view>
<button class="user-card__login-btn" :loading="loading" @tap="handleLogin">
微信登录
@@ -17,7 +16,7 @@
</view>
<!-- Logged in + profile loaded -->
<view v-else-if="loggedIn && hasProfile" class="user-card__user">
<view v-else-if="loggedIn && hasProfile" class="user-card__user" @tap="emit('edit')">
<view class="user-card__avatar-wrap">
<image
class="user-card__avatar-img"
@@ -25,16 +24,15 @@
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>
<text v-if="hasMembership" class="user-card__member-label">会员</text>
</view>
<text v-if="maskedPhone" class="user-card__phone">{{ maskedPhone }}</text>
<text class="user-card__phone">{{ maskedPhone || '完善个人资料' }}</text>
</view>
<text class="user-card__edit">资料 </text>
</view>
<!-- Logged in but profile still loading -->
@@ -54,25 +52,25 @@
<!-- Stats row: shown only when profile is loaded -->
<view v-if="loggedIn && hasProfile" class="user-card__stats">
<view class="user-card__stat-item">
<text class="user-card__stat-value">{{ stats?.totalBookings ?? 0 }}</text>
<text class="user-card__stat-label">总训练()</text>
<text class="user-card__stat-value">{{ stats?.totalBookings ?? '—' }}</text>
<text class="user-card__stat-label">累计上课 · </text>
</view>
<view class="user-card__stat-divider" />
<view class="user-card__stat-item">
<text class="user-card__stat-value">{{ stats?.monthBookings ?? 0 }}</text>
<text class="user-card__stat-label">本月()</text>
<text class="user-card__stat-value">{{ stats?.monthBookings ?? '—' }}</text>
<text class="user-card__stat-label">本月上课 · </text>
</view>
<view class="user-card__stat-divider" />
<view class="user-card__stat-item">
<text class="user-card__stat-value">{{ remainingSessions }}</text>
<text class="user-card__stat-label">剩余课时()</text>
<text class="user-card__stat-label">剩余课时 · </text>
</view>
</view>
</view>
</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'
@@ -87,13 +85,24 @@ const props = defineProps<{
const emit = defineEmits<{
(e: 'login'): void
(e: 'edit'): void
}>()
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 +113,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,
)
// Sum remaining sessions from all active time-based memberships
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 count-limited memberships.
const remainingSessions = computed(() =>
activeMemberships.value
.filter((m) => m.cardType.type === 'TIMES')
.reduce((sum, m) => sum + m.remainingCount, 0),
.filter((m) => m.remainingTimes !== null)
.reduce((sum, m) => sum + toSafeCount(m.remainingTimes), 0),
)
function onAvatarError() {
@@ -135,186 +145,28 @@ function handleLogin() {
<style lang="scss" scoped>
.user-card {
background: linear-gradient(135deg, #7c3aed 0%, #a855f7 50%, #ec4899 100%);
border-radius: 0 0 40rpx 40rpx;
overflow: hidden;
&__header {
padding: 60rpx $spacing-lg $spacing-lg;
}
// ── Guest state ──
&__guest {
display: flex;
align-items: center;
gap: $spacing-md;
}
&__guest-info {
flex: 1;
display: flex;
flex-direction: column;
gap: $spacing-xs;
}
&__guest-title {
font-size: 32rpx;
font-weight: 600;
color: #ffffff;
}
&__guest-sub {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.65);
}
&__login-btn {
flex-shrink: 0;
background: rgba(255, 255, 255, 0.2);
color: #ffffff;
font-size: 26rpx;
font-weight: 500;
border: none;
border-radius: $radius-lg;
padding: 0 $spacing-md;
height: 64rpx;
line-height: 64rpx;
min-width: 160rpx;
&::after {
border: none;
}
}
// ── Logged-in user ──
&__user {
display: flex;
align-items: center;
gap: $spacing-md;
}
&__avatar-wrap {
position: relative;
flex-shrink: 0;
width: 120rpx;
height: 120rpx;
}
&__avatar-img {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
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;
flex-direction: column;
gap: 8rpx;
}
&__name-row {
display: flex;
align-items: center;
gap: $spacing-sm;
}
&__nickname {
font-size: 36rpx;
font-weight: 600;
color: #ffffff;
}
&__phone {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.75);
}
// ── Loading state ──
&__loading {
display: flex;
align-items: center;
gap: $spacing-md;
}
&__avatar-skeleton {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
}
&__nickname-skeleton {
width: 160rpx;
height: 36rpx;
border-radius: $radius-sm;
background: rgba(255, 255, 255, 0.3);
}
&__phone-skeleton {
width: 120rpx;
height: 26rpx;
border-radius: $radius-sm;
background: rgba(255, 255, 255, 0.2);
margin-top: 8rpx;
}
// ── Stats row ──
&__stats {
display: flex;
align-items: stretch;
background: rgba(255, 255, 255, 0.15);
margin: 0 $spacing-lg $spacing-lg;
border-radius: $radius-lg;
padding: $spacing-md 0;
backdrop-filter: blur(10rpx);
}
&__stat-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: $spacing-xs 0;
}
&__stat-divider {
width: 1rpx;
background: rgba(255, 255, 255, 0.3);
margin: $spacing-xs 0;
}
&__stat-value {
font-size: 44rpx;
font-weight: 700;
color: #ffffff;
line-height: 1;
}
&__stat-label {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.8);
margin-top: 6rpx;
}
margin: 20rpx 32rpx 0; padding: 28rpx; background: #f2e9e3; border-radius: 32rpx;
&__user, &__loading { display: flex; align-items: center; gap: 20rpx; }
&__avatar-wrap { width: 108rpx; height: 108rpx; flex-shrink: 0; }
&__avatar-img { width: 100%; height: 100%; box-sizing: border-box; border-radius: 50%; border: 5rpx solid #fcf8f4; }
&__info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10rpx; }
&__name-row { display: flex; align-items: center; flex-wrap: wrap; gap: 10rpx; }
&__nickname { font-size: 36rpx; font-weight: 500; line-height: 1.4; color: #514943; overflow-wrap: anywhere; }
&__member-label { flex-shrink: 0; font-size: 19rpx; color: #8b6c5b; background: #fbf5ef; padding: 4rpx 12rpx; border-radius: 999rpx; }
&__phone { font-size: 24rpx; color: #8b7b70; }
&__edit { flex-shrink: 0; font-size: 22rpx; color: #8b7b70; padding: 16rpx 0 16rpx 8rpx; }
&__stats { display: flex; align-items: stretch; margin-top: 28rpx; padding-top: 26rpx; border-top: 1rpx solid #e3d7ce; }
&__stat-item { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 10rpx; }
&__stat-value { font-size: 40rpx; font-weight: 400; color: #6f5c50; line-height: 1.15; font-variant-numeric: tabular-nums; }
&__stat-label { font-size: 20rpx; color: #8b7b70; }
&__stat-divider { width: 1rpx; margin: 6rpx 0; background: #e3d7ce; }
&__guest { display: flex; align-items: center; flex-wrap: wrap; gap: 20rpx; }
&__guest-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10rpx; }
&__guest-title { font-size: 32rpx; font-weight: 500; color: #514943; }
&__guest-sub { font-size: 23rpx; color: #8b7b70; line-height: 1.6; }
&__login-btn { width: 100%; height: 76rpx; line-height: 76rpx; margin: 4rpx 0 0; padding: 0; border: none; border-radius: 999rpx; background: #6b8276; color: #fff; font-size: 26rpx; font-weight: 400; &::after { border: none; } }
&__avatar-skeleton { width: 108rpx; height: 108rpx; border-radius: 50%; background: #e6d9ce; }
&__nickname-skeleton { width: 150rpx; height: 32rpx; border-radius: 8rpx; background: #e6d9ce; }
&__phone-skeleton { width: 180rpx; height: 22rpx; border-radius: 6rpx; background: #e6d9ce; }
}
</style>

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,155 @@
{
"easycom": {
"autoscan": true
},
"pages": [
{ "path": "pages/admin/analytics", "style": { "navigationStyle": "custom", "enablePullDownRefresh": true } },
{
"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/member-detail",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/admin/member-edit",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/admin/member-supplement",
"style": { "navigationStyle": "custom" }
},
{
"path": "pages/admin/member-arrange",
"style": {
"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 +163,7 @@
"color": "#999999",
"selectedColor": "#1a1a2e",
"backgroundColor": "#ffffff",
"borderStyle": "black",
"borderStyle": "white",
"list": [
{
"pagePath": "pages/home/index",

View File

@@ -0,0 +1,279 @@
<template>
<view class="report" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="统计分析" show-back />
<view class="intro">
<text class="eyebrow">PILATES · MONTHLY REVIEW</text>
<text class="title">每一节课都有迹可循</text>
<text class="muted">工作室月度统计 · 按课程日期归档</text>
</view>
<view class="month-nav">
<button aria-label="上个月" :disabled="month === '2000-01'" @tap="shiftMonth(-1)"></button>
<picker mode="date" fields="month" :value="month" start="2000-01" end="2099-12" @change="changeMonth($event.detail.value)">
<view class="month-label">{{ month.replace('-', ' 年 ') }} </view>
</picker>
<button aria-label="下个月" :disabled="month === '2099-12'" @tap="shiftMonth(1)"></button>
</view>
<view class="toolbar">
<button v-if="month !== currentMonth" @tap="changeMonth(currentMonth)">回到本月</button>
<text v-else>本月持续更新</text>
<button :disabled="loading" @tap="load">刷新数据 </button>
</view>
<view v-if="!loggedIn || !isAdmin" class="empty">仅登录后的管理员可查看教学统计</view>
<view v-else-if="loading" class="empty">正在整理这个月的上课记录</view>
<view v-else-if="error" class="empty"><text>{{ error }}</text><button class="outline" @tap="load">重新加载</button></view>
<template v-else-if="report">
<view class="hero">
<text class="hero-label">已上课程</text>
<view class="hero-number">{{ report.summary.sessions }}<text></text></view>
<text class="hero-note">{{ report.previousMonth }} 全月 {{ report.previous.sessions }} · {{ month === currentMonth ? '本月尚未结束' : '按完整自然月统计' }}</text>
<view class="hero-footer">
<view><text class="metric">{{ hours(report.summary.minutes) }}</text><text>授课小时</text></view>
<view><text class="metric">{{ report.summary.teachingDays }}</text><text>上课天数</text></view>
<view><text class="metric">{{ average }}</text><text>人均上课次数</text></view>
</view>
</view>
<view class="metrics">
<view><text class="metric">{{ report.summary.attendances }}<text class="unit">人次</text></text><text>已完成上课</text></view>
<view><text class="metric">{{ report.summary.students }}<text class="unit"></text></text><text>本月上课学员</text></view>
</view>
<button v-if="reviewCount" class="notice" @tap="showReview"><text>{{ reviewCount }} 条已过结束时间的预约仍待处理</text><text>核对明细 </text></button>
<view v-if="!report.records.length" class="empty compact">这个月暂无预约记录可切换月份查看历史教学情况</view>
<view class="section">
<view class="heading"><text>上课日历</text><text class="muted small">数字为已完成课程节数</text></view>
<view class="calendar">
<text v-for="label in weekdays" :key="label" class="weekday">{{ label }}</text>
<view v-for="n in offset" :key="`blank-${n}`" />
<button v-for="day in days" :key="day.date" class="day" :class="{ taught: day.count > 0, selected: selectedDate === day.date }" :aria-label="`${day.date},已上${day.count}节`" @tap="selectDay(day.date)">
<text>{{ day.day }}</text><text class="day-count">{{ day.count ? `${day.count}` : '·' }}</text>
</button>
</view>
<text class="footnote">点击日期查看当天明细再次点击取消筛选</text>
</view>
<view class="section">
<view class="heading"><text>学员上课排行</text><text class="muted small">已完成 · {{ students.length }} </text></view>
<input v-model="search" class="search" placeholder="搜索学员姓名" :maxlength="60" @input="studentLimit = 10" />
<text v-if="!filteredStudents.length" class="footnote">{{ search ? '没有匹配的上课学员' : '本月还没有已完成的上课记录' }}</text>
<button v-for="(student, index) in filteredStudents.slice(0, studentLimit)" :key="student.id" class="student-row" @tap="selectStudent(student)">
<text class="rank">{{ String(index + 1).padStart(2, '0') }}</text>
<view class="student-main">
<text class="student-name">{{ student.name }}</text>
<view class="track"><view class="fill" :style="{ width: `${student.count / maxCount * 100}%` }" /></view>
<text class="small muted">{{ student.days }} · 最近 {{ student.last.slice(5) }}</text>
</view>
<text class="student-count">{{ student.count }}<text class="small"> </text></text>
</button>
<button v-if="filteredStudents.length > studentLimit" class="more" @tap="studentLimit += 10">查看更多学员</button>
</view>
<view class="section">
<view class="heading"><text>上课用卡分布</text><text class="muted small">已完成人次</text></view>
<view v-for="card in cards" :key="card.name" class="distribution"><text>{{ card.name }}</text><text>{{ card.count }} 人次 · {{ Math.round(card.count / report.summary.attendances * 100) }}%</text></view>
<text v-if="!cards.length" class="footnote">暂无已完成记录</text>
</view>
<view id="lesson-details" class="section">
<view class="heading"><text>上课明细</text><text class="muted small">{{ filteredRecords.length }} </text></view>
<scroll-view scroll-x class="status-scroll"><view class="status-tabs">
<button v-for="item in statuses" :key="item.value" :class="{ active: status === item.value }" @tap="setStatus(item.value)">{{ item.label }} {{ countStatus(item.value) }}</button>
</view></scroll-view>
<view v-if="selectedDate || selectedStudent || reviewOnly" class="filters">
<text>{{ selectedDate || '全月' }}{{ selectedStudent ? ` · ${selectedStudent.name}` : '' }}{{ reviewOnly ? ' · 待核对' : '' }}</text>
<button @tap="clearFilters">清除筛选 ×</button>
</view>
<text v-if="!filteredRecords.length" class="footnote">当前条件下没有上课记录</text>
<view v-for="row in filteredRecords.slice(0, detailLimit)" :key="row.id" class="record">
<view class="record-top"><button class="record-name" @tap="openMember(row.userId)">{{ row.nickname || '未命名学员' }} </button><text class="badge" :class="{ completed: row.status === 'COMPLETED' }">{{ statusName(row.status) }}</text></view>
<text class="record-date">{{ row.date.slice(5).replace('-', '月') }} · {{ row.startTime }}{{ row.endTime }}</text>
<text class="muted small">{{ row.cardName }}{{ row.needsReview ? ' · 已过结束时间,请核对' : '' }}</text>
</view>
<button v-if="filteredRecords.length > detailLimit" class="more" @tap="detailLimit += 20">再显示 20 </button>
<text v-else-if="filteredRecords.length" class="footnote">已显示全部 {{ filteredRecords.length }} 条记录</text>
</view>
<view class="notes">
<text class="notes-title">关于这份月报</text>
<text>统计整个工作室暂不区分老师已上课程按已完成预约的时段去重同一节课多人参加只计 1 节课多人次授课时长按该时段排课时长计算</text>
<text>已完成包含系统自动完成不代表现场签到其他状态不计入已上课程没有日期的历史补录不计入月报用卡分布按当前卡种名称归类</text>
<text>更新于 {{ updatedLabel }} · 下拉可刷新</text>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue'
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { storeToRefs } from 'pinia'
import type { TeachingAnalytics } from '@mp-pilates/shared'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { useAdminStore } from '../../stores/admin'
import { useUserStore } from '../../stores/user'
import { getSystemLayout } from '../../utils/system'
import { getErrorMessage } from '../../utils/auth'
const navBarHeight = `${getSystemLayout().navBarHeight}px`
const { loggedIn, isAdmin } = storeToRefs(useUserStore())
const store = useAdminStore()
const currentMonth = ref(chinaMonth())
const month = ref(currentMonth.value)
const report = ref<TeachingAnalytics | null>(null)
const loading = ref(false)
const error = ref('')
const selectedDate = ref('')
const selectedStudent = ref<{ id: string; name: string } | null>(null)
const status = ref('ALL')
const reviewOnly = ref(false)
const search = ref('')
const studentLimit = ref(10)
const detailLimit = ref(20)
let requestId = 0
const weekdays = ['一', '二', '三', '四', '五', '六', '日']
const statuses = [
{ value: 'ALL', label: '全部' }, { value: 'COMPLETED', label: '已完成' },
{ value: 'CONFIRMED', label: '已确认' }, { value: 'PENDING_CONFIRMATION', label: '待确认' },
{ value: 'CANCELLED', label: '已取消' }, { value: 'NO_SHOW', label: '未出席' },
]
const rows = computed(() => report.value?.records ?? [])
const completed = computed(() => rows.value.filter(row => row.status === 'COMPLETED'))
const average = computed(() => report.value?.summary.students ? (report.value.summary.attendances / report.value.summary.students).toFixed(1) : '—')
const reviewCount = computed(() => rows.value.filter(row => row.needsReview).length)
const offset = computed(() => (new Date(`${month.value}-01T00:00:00Z`).getUTCDay() + 6) % 7)
const days = computed(() => {
const [year, number] = month.value.split('-').map(Number)
const counts = new Map<string, Set<string>>()
completed.value.forEach(row => {
if (!counts.has(row.date)) counts.set(row.date, new Set())
counts.get(row.date)!.add(row.slotId)
})
return Array.from({ length: new Date(Date.UTC(year, number, 0)).getUTCDate() }, (_, i) => {
const date = `${month.value}-${String(i + 1).padStart(2, '0')}`
return { date, day: i + 1, count: counts.get(date)?.size ?? 0 }
})
})
const students = computed(() => {
const map = new Map<string, { id: string; name: string; count: number; dates: Set<string>; last: string }>()
completed.value.forEach(row => {
const student = map.get(row.userId) ?? { id: row.userId, name: row.nickname || '未命名学员', count: 0, dates: new Set<string>(), last: row.date }
student.count++
student.dates.add(row.date)
if (row.date > student.last) student.last = row.date
map.set(row.userId, student)
})
return [...map.values()].map(({ dates, ...student }) => ({ ...student, days: dates.size }))
.sort((a, b) => b.count - a.count || a.id.localeCompare(b.id))
})
const filteredStudents = computed(() => students.value.filter(student => student.name.toLowerCase().includes(search.value.trim().toLowerCase())))
const maxCount = computed(() => students.value[0]?.count || 1)
const cards = computed(() => {
const map = new Map<string, number>()
completed.value.forEach(row => map.set(row.cardName, (map.get(row.cardName) ?? 0) + 1))
return [...map].map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count)
})
const scopedRecords = computed(() => rows.value.filter(row =>
(!selectedDate.value || row.date === selectedDate.value) &&
(!selectedStudent.value || row.userId === selectedStudent.value.id) &&
(!reviewOnly.value || row.needsReview)))
const filteredRecords = computed(() => scopedRecords.value.filter(row => status.value === 'ALL' || row.status === status.value).slice().reverse())
const updatedLabel = computed(() => report.value ? new Date(new Date(report.value.generatedAt).getTime() + 8 * 3600000).toISOString().slice(0, 16).replace('T', ' ') : '')
function chinaMonth() { return new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 7) }
function hours(minutes: number) { return Number((minutes / 60).toFixed(1)) }
function countStatus(value: string) { return scopedRecords.value.filter(row => value === 'ALL' || row.status === value).length }
function statusName(value: string) { return statuses.find(item => item.value === value)?.label ?? value }
function clearFilters() {
selectedDate.value = ''; selectedStudent.value = null; reviewOnly.value = false
status.value = 'ALL'; detailLimit.value = 20
}
function setStatus(value: string) { status.value = value; detailLimit.value = 20 }
async function scrollDetails() {
await nextTick()
uni.pageScrollTo({ selector: '#lesson-details', offsetTop: -getSystemLayout().navBarHeight - 12, duration: 250 })
}
function selectDay(date: string) {
selectedDate.value = selectedDate.value === date ? '' : date
reviewOnly.value = false; detailLimit.value = 20; scrollDetails()
}
function selectStudent(student: { id: string; name: string }) {
selectedStudent.value = { id: student.id, name: student.name }
selectedDate.value = ''; reviewOnly.value = false; setStatus('COMPLETED'); scrollDetails()
}
function showReview() { clearFilters(); reviewOnly.value = true; scrollDetails() }
function openMember(id: string) { uni.navigateTo({ url: `/pages/admin/member-detail?userId=${encodeURIComponent(id)}` }) }
function shiftMonth(amount: number) {
const [year, number] = month.value.split('-').map(Number)
changeMonth(new Date(Date.UTC(year, number - 1 + amount, 1)).toISOString().slice(0, 7))
}
function changeMonth(value: string) {
if (value === month.value || value < '2000-01' || value > '2099-12') return
month.value = value; clearFilters(); search.value = ''; studentLimit.value = 10; load()
}
async function load() {
const id = ++requestId
report.value = null; error.value = ''; loading.value = false
if (!loggedIn.value || !isAdmin.value) return
loading.value = true
try {
const data = await store.fetchTeachingAnalytics(month.value)
if (id === requestId) report.value = data
} catch (err) {
if (id === requestId) error.value = getErrorMessage(err, '统计暂时无法加载,请重试')
} finally {
if (id === requestId) loading.value = false
}
}
onShow(() => { currentMonth.value = chinaMonth(); load() })
onPullDownRefresh(async () => { try { await load() } finally { uni.stopPullDownRefresh() } })
</script>
<style scoped lang="scss">
.report { min-height: 100vh; box-sizing: border-box; background: #f7f5ef; color: #35483e; padding: 0 30rpx calc(48rpx + env(safe-area-inset-bottom)); }
button { margin: 0; padding: 0; background: transparent; border-radius: 0; font-size: inherit; color: inherit; line-height: 1.5; &::after { border: 0; } &:active { opacity: .65; } }
.intro { padding: 40rpx 4rpx 28rpx; display: flex; flex-direction: column; gap: 14rpx; }
.eyebrow { font-size: 18rpx; letter-spacing: 4rpx; color: #687d6e; }
.title { font-family: 'Songti SC', 'STSong', serif; font-size: 40rpx; line-height: 1.6; }
.muted { color: #73796f; font-size: 23rpx; }
.small { font-size: 22rpx; }
.month-nav { display: flex; justify-content: space-between; align-items: center; border-top: 1rpx solid #d9ddd2; border-bottom: 1rpx solid #d9ddd2; button { width: 80rpx; line-height: 88rpx; font-size: 40rpx; } }
.month-label { padding: 20rpx; font-size: 33rpx; font-family: 'Songti SC', 'STSong', serif; }
.toolbar { display: flex; justify-content: space-between; align-items: center; font-size: 22rpx; color: #677765; min-height: 76rpx; button { padding: 16rpx 0; } }
.hero { background: #344f42; color: #faf7e9; border-radius: 12rpx 12rpx 48rpx 12rpx; padding: 36rpx; }
.hero-label { font-size: 25rpx; letter-spacing: 3rpx; }
.hero-number { font-family: 'Baskerville', 'Times New Roman', serif; font-size: 116rpx; line-height: 1.25; text { font-size: 26rpx; padding-left: 18rpx; } }
.hero-note { font-size: 21rpx; color: #d1dbc9; }
.hero-footer { display: flex; margin-top: 30rpx; padding-top: 26rpx; border-top: 1rpx solid #6d8070; gap: 18rpx; > view { flex: 1; display: flex; flex-direction: column; font-size: 21rpx; gap: 8rpx; } }
.metric { font-size: 40rpx; font-family: 'Baskerville', 'Times New Roman', serif; font-variant-numeric: tabular-nums; }
.unit { font-size: 22rpx; margin-left: 14rpx; }
.metrics { display: flex; padding: 30rpx 0; border-bottom: 1rpx solid #d9ddd2; > view { flex: 1; display: flex; flex-direction: column; gap: 12rpx; font-size: 24rpx; padding-left: 30rpx; &:last-child { border-left: 1rpx solid #d9ddd2; } } }
.notice { width: 100%; text-align: left; margin-top: 26rpx; padding: 24rpx; display: flex; flex-direction: column; gap: 12rpx; background: #f0e5d2; color: #805c30; font-size: 23rpx; border-radius: 12rpx; }
.section { margin-top: 28rpx; background: #fffefa; border: 1rpx solid #e3e5da; border-radius: 16rpx; padding: 28rpx; }
.heading { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 12rpx; margin-bottom: 24rpx; font-size: 30rpx; }
.heading > text:first-child { font-family: 'Songti SC', 'STSong', serif; }
.calendar { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 8rpx; }
.weekday { text-align: center; font-size: 21rpx; color: #73796f; padding-bottom: 14rpx; }
.day { display: flex; flex-direction: column; justify-content: center; align-items: center; min-height: 92rpx; border-radius: 10rpx; font-size: 25rpx; border: 2rpx solid transparent; }
.day-count { font-size: 18rpx; margin-top: 7rpx; color: #66785f; }
.taught { background: #e8eee2; }
.selected { border-color: #344f42; background: #344f42; color: #fff; .day-count { color: #fff; } }
.footnote { display: block; color: #73796f; font-size: 22rpx; line-height: 1.8; padding-top: 24rpx; }
.search { background: #f2f3ec; border-radius: 10rpx; padding: 20rpx; font-size: 25rpx; margin-bottom: 14rpx; }
.student-row { display: flex; align-items: center; gap: 20rpx; width: 100%; padding: 24rpx 0; text-align: left; border-bottom: 1rpx solid #eeeee5; }
.rank { color: #77836e; font-family: 'Baskerville', serif; font-size: 26rpx; }
.student-main { flex: 1; min-width: 0; }
.student-name { display: block; font-size: 28rpx; word-break: break-all; }
.track { height: 5rpx; background: #eef0e6; margin: 14rpx 0 8rpx; }
.fill { height: 100%; background: #91a180; }
.student-count { flex-shrink: 0; font-size: 34rpx; }
.more { padding: 24rpx 0 0; width: 100%; font-size: 24rpx; color: #52714e; }
.distribution { display: flex; justify-content: space-between; gap: 20rpx; padding: 20rpx 0; border-bottom: 1rpx solid #eeeee5; font-size: 24rpx; > text:first-child { flex: 1; word-break: break-all; } }
.status-scroll { width: 100%; }
.status-tabs { display: flex; gap: 12rpx; padding-bottom: 12rpx; white-space: nowrap; button { flex-shrink: 0; padding: 16rpx 20rpx; border-radius: 8rpx; background: #f1f2eb; font-size: 22rpx; } .active { color: white; background: #344f42; } }
.filters { margin-top: 16rpx; font-size: 22rpx; color: #647b58; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; button { padding: 16rpx 0; } }
.record { display: flex; flex-direction: column; gap: 12rpx; border-bottom: 1rpx solid #e9ebdf; padding: 24rpx 0; }
.record-top { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; }
.record-name { font-size: 28rpx; text-align: left; word-break: break-all; }
.badge { flex-shrink: 0; font-size: 20rpx; color: #867257; background: #f3eee4; padding: 6rpx 12rpx; border-radius: 6rpx; }
.completed { color: #53704b; background: #eaf0e2; }
.record-date { font-size: 25rpx; }
.notes { padding: 32rpx 8rpx; display: flex; flex-direction: column; gap: 16rpx; font-size: 22rpx; line-height: 1.9; color: #73796f; }
.notes-title { color: #455d49; font-size: 25rpx; }
.empty { padding: 80rpx 28rpx; text-align: center; font-size: 26rpx; line-height: 1.8; color: #737e70; }
.compact { padding: 36rpx 20rpx 0; }
.outline { padding: 20rpx; margin-top: 24rpx; border: 1rpx solid #b2bfaa; border-radius: 12rpx; }
</style>

View File

@@ -0,0 +1,806 @@
<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 { onShow } from '@dcloudio/uni-app'
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)
const hasLoadedOnce = 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, opts: { silent?: boolean } = {}) {
if (loading.value) return
if (!opts.silent) 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. Skip in silent mode — history is only
// shown as a small inline preview, and the detail page has the full one.
if (!append && !opts.silent) {
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)
if (!opts.silent) uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
if (!opts.silent) 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()
hasLoadedOnce.value = true
})
// After returning from booking detail (where status may have changed),
// re-sync the list with the server. Local row actions still call onRefresh
// directly — this onShow is the safety net for the navigate-back path.
// Uses silent mode so the list stays visible (no skeleton flash).
onShow(() => {
if (!hasLoadedOnce.value) return
// Skip while a refresh is already in flight to avoid overlap.
if (refreshing.value || loading.value) return
Promise.all([
loadBookings(false, { silent: true }),
loadAllForStats(),
]).catch(() => {
// Errors are non-fatal here — list keeps showing stale data until
// the next explicit refresh.
})
})
</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,210 @@
<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>
<view class="section-header"><text class="section-title">教学报告</text></view>
<view class="list">
<view class="list-item" @tap="navigate('/pages/admin/analytics')">
<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">月度课次 · 学员出勤 · 上课明细</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: 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 +221,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>

View File

@@ -0,0 +1,574 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="安排课程" show-back />
<view v-if="detail" class="who-bar">
<view class="who-avatar">
<image v-if="detail.user.avatarUrl" class="avatar-img" :src="detail.user.avatarUrl" mode="aspectFill" />
<view v-else class="avatar-fallback">
<text class="avatar-letter">{{ (detail.user.nickname || '?').slice(0, 1) }}</text>
</view>
</view>
<view class="who-copy">
<text class="who-kicker">为学员安排</text>
<text class="who-name">{{ detail.user.nickname || '未知用户' }}</text>
</view>
</view>
<view v-if="usableMemberships.length" class="card-switch">
<scroll-view scroll-x class="card-scroll" :show-scrollbar="false">
<view class="card-track">
<view
v-for="card in usableMemberships"
:key="card.id"
class="card-pill"
:class="{ 'card-pill--on': selectedMembershipId === card.id }"
@tap="selectedMembershipId = card.id"
>
<text class="card-pill-name">{{ card.cardType.name }}</text>
<text class="card-pill-meta">
{{ card.remainingTimes === null ? '有效期内不限次' : `${card.remainingTimes}` }}
</text>
</view>
</view>
</scroll-view>
</view>
<view v-else class="ban-banner">
<text class="ban-text">该会员没有可扣课的有效卡请先开卡</text>
</view>
<DateSelector v-model="selectedDate" variant="booking" @select="onDateSelect" />
<TimePeriodFilter v-model="selectedPeriod" variant="booking" />
<view v-if="slotsLoading" class="slot-skeleton">
<view v-for="i in 4" :key="i" class="slot-skel" />
</view>
<view v-else-if="filteredSlots.length === 0" class="empty-slots">
<text class="empty-title">这天还没有可安排的课表</text>
<text class="empty-sub">请先去排课管理发布时段</text>
<view class="ghost-link" @tap="goSchedule">
<text class="ghost-link-text">前往排课管理</text>
</view>
</view>
<view v-else class="slot-list">
<view
v-for="slot in filteredSlots"
:key="slot.id"
class="slot-row"
:class="{ 'slot-row--disabled': !canPickSlot(slot) }"
@tap="onPickSlot(slot)"
>
<view class="slot-time-col">
<text class="slot-time">{{ slot.startTime.slice(0, 5) }}</text>
<text class="slot-end">{{ slot.endTime.slice(0, 5) }}</text>
</view>
<view class="slot-body">
<text class="slot-title">{{ slotLabel(slot) }}</text>
<text class="slot-cap">{{ slot.bookedCount }}/{{ slot.capacity }} </text>
</view>
<text class="slot-action">{{ slotActionLabel(slot) }}</text>
</view>
</view>
<view v-if="confirmVisible && pendingSlot" class="mask" @tap="confirmVisible = false">
<view class="sheet" @tap.stop>
<text class="sheet-kicker">立即确认</text>
<text class="sheet-title">安排给 {{ detail?.user.nickname || '该会员' }}</text>
<view class="sheet-lines">
<view class="sheet-line">
<text class="sheet-label">时间</text>
<text class="sheet-value">{{ pendingSlot.date }} {{ pendingSlot.startTime.slice(0, 5) }}{{ pendingSlot.endTime.slice(0, 5) }}</text>
</view>
<view class="sheet-line">
<text class="sheet-label">扣卡</text>
<text class="sheet-value">{{ selectedMembership?.cardType.name }}</text>
</view>
</view>
<text class="sheet-note">{{ deductHint }}</text>
<view class="sheet-actions">
<view class="sheet-cancel" @tap="confirmVisible = false">
<text class="sheet-cancel-text">取消</text>
</view>
<view class="sheet-ok" :class="{ 'sheet-ok--disabled': arranging }" @tap="confirmArrange">
<text class="sheet-ok-text">{{ arranging ? '安排中...' : '确认安排' }}</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import type {
AdminMemberDetail,
ScheduleSlotPreview,
} from '@mp-pilates/shared'
import { MembershipStatus, TIME_PERIODS, TimeSlotStatus } from '@mp-pilates/shared'
import CustomNavBar from '../../components/CustomNavBar.vue'
import DateSelector from '../../components/DateSelector.vue'
import TimePeriodFilter from '../../components/TimePeriodFilter.vue'
import { getSystemLayout } from '../../utils/system'
import { formatDate, isSlotPast } from '../../utils/format'
import { getErrorMessage } from '../../utils/auth'
import { useAdminStore } from '../../stores/admin'
type PeriodKey = keyof typeof TIME_PERIODS | null
const adminStore = useAdminStore()
const navBarHeight = ref('64px')
const userId = ref('')
const detail = ref<AdminMemberDetail | null>(null)
const selectedMembershipId = ref('')
const selectedDate = ref(formatDate(new Date()))
const selectedPeriod = ref<PeriodKey>(null)
const slots = ref<ScheduleSlotPreview[]>([])
const slotsLoading = ref(false)
const confirmVisible = ref(false)
const pendingSlot = ref<ScheduleSlotPreview | null>(null)
const arranging = ref(false)
const usableMemberships = computed(() =>
(detail.value?.memberships ?? []).filter((item) =>
item.status === MembershipStatus.ACTIVE
&& (item.remainingTimes === null || item.remainingTimes > 0)
&& new Date(item.expireDate) > new Date(),
),
)
const selectedMembership = computed(
() => usableMemberships.value.find((item) => item.id === selectedMembershipId.value) ?? null,
)
const canArrange = computed(() => Boolean(selectedMembership.value))
const isCountLimited = computed(() => selectedMembership.value?.remainingTimes !== null)
const deductHint = computed(() => {
if (!isCountLimited.value) {
return '将立即确认该课,会员卡不限次,会员无需再确认。'
}
return '将立即确认该课并扣除 1 次,会员无需再确认。'
})
const publishedSlots = computed(() =>
slots.value.filter((slot): slot is ScheduleSlotPreview & { id: string } =>
Boolean(slot.isPublished && slot.id),
),
)
const filteredSlots = computed(() => {
if (!selectedPeriod.value) return publishedSlots.value
const period = TIME_PERIODS[selectedPeriod.value]
return publishedSlots.value.filter((slot) => slot.startTime >= period.start && slot.startTime < period.end)
})
function canPickSlot(slot: ScheduleSlotPreview): boolean {
if (!canArrange.value) return false
if (isSlotPast(slot.date, slot.startTime)) return false
if (slot.status === TimeSlotStatus.CLOSED) return false
if (slot.status === TimeSlotStatus.FULL || slot.bookedCount >= slot.capacity) return false
return true
}
function slotLabel(slot: ScheduleSlotPreview): string {
if (slot.status === TimeSlotStatus.CLOSED) return '已关闭'
if (slot.status === TimeSlotStatus.FULL || slot.bookedCount >= slot.capacity) return '已满员'
if (isSlotPast(slot.date, slot.startTime)) return '已过点'
return '可安排'
}
function slotActionLabel(slot: ScheduleSlotPreview): string {
if (!canPickSlot(slot)) return '—'
return '安排'
}
async function loadSlots(date: string) {
slotsLoading.value = true
try {
slots.value = await adminStore.previewScheduleByDate(date)
} catch (err: unknown) {
slots.value = []
uni.showToast({ title: getErrorMessage(err, '课表加载失败'), icon: 'none' })
} finally {
slotsLoading.value = false
}
}
async function loadPage() {
detail.value = await adminStore.fetchMemberDetail(userId.value)
const first = usableMemberships.value[0]
selectedMembershipId.value = first?.id ?? ''
await loadSlots(selectedDate.value)
}
function onDateSelect(date: string) {
selectedDate.value = date
loadSlots(date)
}
function goSchedule() {
uni.navigateTo({ url: '/pages/admin/schedule' })
}
function onPickSlot(slot: ScheduleSlotPreview) {
if (!canPickSlot(slot) || !slot.id) return
pendingSlot.value = slot
confirmVisible.value = true
}
async function confirmArrange() {
const slot = pendingSlot.value
const membership = selectedMembership.value
if (!slot?.id || !membership || arranging.value) return
arranging.value = true
try {
await adminStore.arrangeMemberBooking({
userId: userId.value,
membershipId: membership.id,
timeSlotId: slot.id,
})
confirmVisible.value = false
uni.showToast({ title: '已安排并确认', icon: 'success' })
setTimeout(() => uni.navigateBack(), 500)
} catch (err: unknown) {
uni.showToast({ title: getErrorMessage(err, '安排失败'), icon: 'none' })
} finally {
arranging.value = false
}
}
onLoad((query) => {
userId.value = String(query?.userId || '')
})
onMounted(async () => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
try {
await loadPage()
} catch (err: unknown) {
uni.showToast({ title: getErrorMessage(err, '加载失败'), icon: 'none' })
}
})
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: $bg-page;
padding-bottom: 80rpx;
}
.who-bar {
margin: 16rpx 24rpx 0;
padding: 20rpx;
border-radius: 20rpx;
background: linear-gradient(135deg, #3c3228, #5a4a3a);
display: flex;
align-items: center;
gap: 18rpx;
}
.who-avatar {
width: 72rpx;
height: 72rpx;
border-radius: 18rpx;
overflow: hidden;
flex-shrink: 0;
}
.avatar-img { width: 100%; height: 100%; }
.avatar-fallback {
width: 100%;
height: 100%;
background: $accent-color;
display: flex;
align-items: center;
justify-content: center;
}
.avatar-letter {
color: #fff8f0;
font-weight: 700;
}
.who-copy {
display: flex;
flex-direction: column;
gap: 4rpx;
}
.who-kicker {
font-size: 18rpx;
letter-spacing: 3rpx;
color: rgba(255, 248, 240, 0.55);
}
.who-name {
font-size: 32rpx;
font-weight: 700;
color: #fff8f0;
}
.card-switch {
padding: 16rpx 0 4rpx;
}
.card-scroll { white-space: nowrap; }
.card-track {
display: inline-flex;
gap: 12rpx;
padding: 0 24rpx;
}
.card-pill {
padding: 14rpx 22rpx;
border-radius: 16rpx;
background: #fff;
border: 2rpx solid rgba(180, 160, 130, 0.18);
display: flex;
flex-direction: column;
gap: 4rpx;
}
.card-pill--on {
border-color: $brand-color;
background: #3c3228;
}
.card-pill-name {
font-size: 24rpx;
font-weight: 700;
color: $text-primary;
}
.card-pill--on .card-pill-name,
.card-pill--on .card-pill-meta {
color: #fff8f0;
}
.card-pill-meta {
font-size: 20rpx;
color: $text-hint;
}
.ban-banner {
margin: 16rpx 24rpx;
padding: 20rpx;
border-radius: 16rpx;
background: rgba($error-color, 0.1);
}
.ban-text {
font-size: 24rpx;
color: $error-color;
}
.slot-skeleton { padding: 16rpx 24rpx; }
.slot-skel {
height: 112rpx;
border-radius: 18rpx;
margin-bottom: 12rpx;
background: linear-gradient(90deg, #efe8df 25%, #f7f2ea 50%, #efe8df 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
.empty-slots {
padding: 48rpx 32rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 10rpx;
}
.empty-title {
font-size: 28rpx;
font-weight: 700;
color: $text-primary;
}
.empty-sub {
font-size: 24rpx;
color: $text-hint;
text-align: center;
}
.ghost-link {
margin-top: 8rpx;
padding: 10rpx 24rpx;
border-radius: 999rpx;
border: 1rpx solid $brand-color;
}
.ghost-link-text {
font-size: 22rpx;
color: $brand-color;
}
.slot-list {
padding: 16rpx 24rpx 8rpx;
}
.slot-row {
display: flex;
align-items: center;
gap: 20rpx;
background: #fff;
border-radius: 18rpx;
padding: 22rpx 20rpx;
margin-bottom: 12rpx;
border: 1rpx solid rgba(180, 160, 130, 0.12);
}
.slot-row--disabled {
opacity: 0.45;
}
.slot-time-col {
width: 120rpx;
display: flex;
flex-direction: column;
}
.slot-time {
font-size: 34rpx;
font-weight: 800;
color: $text-primary;
font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif;
}
.slot-end {
font-size: 22rpx;
color: $text-hint;
}
.slot-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 4rpx;
}
.slot-title {
font-size: 26rpx;
font-weight: 600;
color: $text-primary;
}
.slot-cap {
font-size: 22rpx;
color: $text-hint;
}
.slot-action {
font-size: 24rpx;
font-weight: 700;
color: $accent-color;
}
.mask {
position: fixed;
inset: 0;
background: rgba(44, 36, 28, 0.45);
display: flex;
align-items: flex-end;
z-index: 20;
}
.sheet {
width: 100%;
background: #fff8f0;
border-radius: 28rpx 28rpx 0 0;
padding: 32rpx 32rpx calc(32rpx + env(safe-area-inset-bottom));
}
.sheet-kicker {
font-size: 20rpx;
letter-spacing: 4rpx;
color: $text-hint;
}
.sheet-title {
display: block;
margin-top: 8rpx;
font-size: 36rpx;
font-weight: 700;
color: $text-primary;
font-family: 'Songti SC', Georgia, serif;
}
.sheet-lines {
margin: 28rpx 0 16rpx;
}
.sheet-line {
display: flex;
justify-content: space-between;
padding: 14rpx 0;
border-bottom: 1rpx solid rgba(180, 160, 130, 0.14);
}
.sheet-label {
font-size: 24rpx;
color: $text-hint;
}
.sheet-value {
font-size: 26rpx;
color: $text-primary;
}
.sheet-note {
display: block;
font-size: 24rpx;
color: $text-secondary;
line-height: 1.5;
}
.sheet-actions {
display: flex;
gap: 16rpx;
margin-top: 28rpx;
}
.sheet-cancel,
.sheet-ok {
flex: 1;
height: 84rpx;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
}
.sheet-cancel {
background: #fff;
border: 2rpx solid $brand-color;
}
.sheet-ok {
background: $brand-color;
}
.sheet-ok--disabled { opacity: 0.5; }
.sheet-cancel-text {
font-size: 28rpx;
font-weight: 700;
color: $brand-color;
}
.sheet-ok-text {
font-size: 28rpx;
font-weight: 700;
color: #fff8f0;
}
</style>

View File

@@ -0,0 +1,408 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="会员档案" show-back />
<view v-if="loading && !detail" class="skeleton-wrap">
<view class="skeleton-hero" />
<view class="skeleton-block" />
<view class="skeleton-block" />
</view>
<template v-else-if="detail">
<view class="hero">
<view class="hero-inner">
<view class="hero-avatar">
<image v-if="detail.user.avatarUrl" class="avatar-img" :src="detail.user.avatarUrl" mode="aspectFill" />
<view v-else class="avatar-fallback">
<text class="avatar-letter">{{ (detail.user.nickname || '?').slice(0, 1) }}</text>
</view>
</view>
<view class="hero-copy">
<text class="hero-name">{{ detail.user.nickname || '未知用户' }}</text>
<text class="hero-phone">{{ detail.user.phone || '未绑定手机' }}</text>
</view>
</view>
<view v-if="detail.user.openid" class="member-id" @tap="copyOpenid">
<text class="member-id-label">微信标识</text>
<text class="member-id-value">{{ detail.user.openid }}</text>
<text class="member-id-copy">复制</text>
</view>
<view class="time-pair">
<view class="time-cell">
<text class="time-label">注册时间</text>
<text class="time-value">{{ formatDateTimeFull(detail.user.createdAt) }}</text>
</view>
<view class="time-rule" />
<view class="time-cell">
<text class="time-label">最近登录</text>
<text class="time-value">{{ detail.user.lastLoginAt ? formatDateTimeFull(detail.user.lastLoginAt) : '暂无登录记录' }}</text>
</view>
</view>
</view>
<view class="section section--practice">
<view class="section-heading">
<text class="section-label">上课情况</text>
<button class="supplement-entry" @tap="goSupplement">补录上课 <text></text></button>
</view>
<view class="stats-strip">
<view class="stat">
<text class="stat-num">{{ detail.stats.totalBookings }}</text>
<text class="stat-name">累计预约</text>
</view>
<view class="stat stat--completed">
<text class="stat-num">{{ detail.stats.completedBookings }}</text>
<text class="stat-name">累计上课</text>
</view>
<view class="stat">
<text class="stat-num">{{ detail.stats.cancelledBookings }}</text>
<text class="stat-name">已取消</text>
</view>
<view class="stat">
<text class="stat-num">{{ detail.stats.noShowBookings }}</text>
<text class="stat-name">未到课</text>
</view>
</view>
</view>
<view class="section">
<view class="section-heading">
<text class="section-label">会员卡</text>
<text class="section-note">{{ detail.memberships.length }} </text>
</view>
<view v-if="detail.memberships.length" class="card-list">
<view
v-for="card in detail.memberships"
:key="card.id"
class="mship"
>
<view class="mship-head">
<view class="mship-titles">
<text class="mship-name">{{ card.cardType.name }}</text>
<text class="mship-type">{{ getCardTypeLabel(card.cardType.type) }}</text>
</view>
<view class="mship-status" :class="'mship-status--' + card.status.toLowerCase()">
<text class="mship-status-text">{{ membershipStatusLabel(card.status) }}</text>
</view>
</view>
<view v-if="card.remainingTimes !== null" class="mship-times">
<text class="mship-times-num">{{ card.remainingTimes }}</text>
<text class="mship-times-unit">次可用</text>
</view>
<view v-else class="mship-duration">
<text class="mship-duration-title">有效期内使用</text>
<text class="mship-times-unit">不限次数</text>
</view>
<view v-if="card.remainingTimes !== null && getMembershipTotalTimes(card)" class="progress">
<view class="progress-bar">
<view class="progress-fill" :style="{ width: getMembershipProgressWidth(card) }" />
</view>
<text class="progress-text">
已用 {{ getMembershipUsedTimes(card) }} / {{ getMembershipTotalTimes(card) }}
</text>
</view>
<view class="mship-dates">
<text>{{ formatDate(card.startDate) }} </text>
<text>{{ formatDate(card.expireDate) }} </text>
</view>
</view>
</view>
<view v-else class="empty-card">
<text class="empty-card-title">尚未开卡</text>
<text class="empty-card-sub">开通体验卡次卡或月卡后即可安排课程</text>
<view class="empty-card-btn" @tap="goEdit">
<text class="empty-card-btn-text">去开卡</text>
</view>
</view>
</view>
<view class="section section--last">
<view class="section-heading">
<text class="section-label">即将上课</text>
<text v-if="detail.upcomingBookings.length" class="section-note">{{ detail.upcomingBookings.length }} 节待上</text>
</view>
<view v-if="detail.upcomingBookings.length" class="upcoming-list">
<view v-for="item in detail.upcomingBookings" :key="item.id" class="upcoming-row">
<view class="upcoming-time">
<text class="upcoming-date">{{ item.date.slice(5, 10).replace('-', ' / ') }}</text>
<text class="upcoming-hour">{{ item.startTime.slice(0, 5) }}{{ item.endTime.slice(0, 5) }}</text>
</view>
<view class="upcoming-meta">
<text class="upcoming-card">{{ item.cardName }}</text>
<text class="upcoming-status">{{ bookingStatusLabel(item.status) }}</text>
</view>
</view>
</view>
<view v-else class="upcoming-empty">
<text class="upcoming-empty-text">近期没有待上的课</text>
</view>
</view>
</template>
<view class="dock">
<view class="dock-btn dock-btn--ghost" @tap="goEdit">
<text class="dock-btn-text">编辑资料</text>
</view>
<view
class="dock-btn dock-btn--solid"
:class="{ 'dock-btn--disabled': !canArrange }"
@tap="goArrange"
>
<text class="dock-btn-text dock-btn-text--solid">安排课程</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import type { AdminMemberDetail, MembershipWithCardType } from '@mp-pilates/shared'
import { MembershipStatus, BookingStatus } from '@mp-pilates/shared'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import {
formatDateTimeFull,
getCardTypeLabel,
getMembershipProgressWidth,
getMembershipUsedTimes,
getMembershipTotalTimes,
} from '../../utils/format'
import { BOOKING_STATUS_LABELS } from '../../utils/booking-helpers'
import { getErrorMessage } from '../../utils/auth'
import { useAdminStore } from '../../stores/admin'
const adminStore = useAdminStore()
const navBarHeight = ref('64px')
const userId = ref('')
const loading = ref(false)
const detail = ref<AdminMemberDetail | null>(null)
const canArrange = computed(() => (detail.value?.memberships ?? []).some(isArrangableMembership))
function isArrangableMembership(membership: MembershipWithCardType): boolean {
if (membership.status !== MembershipStatus.ACTIVE) return false
if (membership.remainingTimes !== null && membership.remainingTimes <= 0) return false
return new Date(membership.expireDate) > new Date()
}
function membershipStatusLabel(status: string): string {
const map: Record<string, string> = {
ACTIVE: '有效',
EXPIRED: '已过期',
USED_UP: '已用完',
}
return map[status] || status
}
function bookingStatusLabel(status: BookingStatus): string {
return BOOKING_STATUS_LABELS[status] || status
}
function formatDate(dateStr: string): string {
return dateStr.slice(0, 10)
}
function copyOpenid() {
const openid = detail.value?.user.openid
if (!openid) return
uni.setClipboardData({
data: openid,
success: () => uni.showToast({ title: '已复制 OpenID', icon: 'success' }),
})
}
async function loadDetail() {
if (!userId.value) return
loading.value = true
try {
detail.value = await adminStore.fetchMemberDetail(userId.value)
} catch (err: unknown) {
uni.showToast({ title: getErrorMessage(err, '加载失败'), icon: 'none' })
} finally {
loading.value = false
}
}
function goSupplement() {
if (userId.value) uni.navigateTo({ url: `/pages/admin/member-supplement?userId=${userId.value}` })
}
function goEdit() {
if (!userId.value) return
uni.navigateTo({ url: `/pages/admin/member-edit?userId=${userId.value}` })
}
function goArrange() {
if (!canArrange.value) {
uni.showToast({ title: '请先开通有效会员卡', icon: 'none' })
return
}
uni.navigateTo({ url: `/pages/admin/member-arrange?userId=${userId.value}` })
}
onLoad((query) => {
userId.value = String(query?.userId || '')
})
onMounted(() => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
})
onShow(() => {
if (userId.value) {
loadDetail()
}
})
</script>
<style lang="scss" scoped>
.page {
--ink: #514943;
--muted: #8b817b;
--line: #eee8e3;
--sage: #617d73;
min-height: 100vh;
box-sizing: border-box;
background: #fbf9f6;
color: var(--ink);
padding-bottom: calc(152rpx + env(safe-area-inset-bottom));
}
.skeleton-wrap { padding: 28rpx 32rpx; }
.skeleton-hero, .skeleton-block {
border-radius: 28rpx;
background: linear-gradient(90deg, #f0eae5 25%, #faf7f3 50%, #f0eae5 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
.skeleton-hero { height: 300rpx; margin-bottom: 28rpx; }
.skeleton-block { height: 180rpx; margin-bottom: 28rpx; }
.hero {
margin: 28rpx 32rpx 0;
padding: 32rpx;
border-radius: 32rpx;
background: #f3eae5;
}
.hero-inner { display: flex; align-items: center; gap: 24rpx; }
.hero-avatar {
width: 112rpx;
height: 112rpx;
border-radius: 50%;
overflow: hidden;
border: 6rpx solid #fcf8f4;
flex-shrink: 0;
}
.avatar-img { width: 100%; height: 100%; }
.avatar-fallback {
width: 100%; height: 100%; background: #e0cec4;
display: flex; align-items: center; justify-content: center;
}
.avatar-letter {
font-family: 'Songti SC', 'STSong', serif;
font-size: 44rpx; font-weight: 400; color: #735e53;
}
.hero-copy { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 12rpx; }
.hero-name { font-size: 38rpx; font-weight: 500; line-height: 1.4; overflow-wrap: anywhere; }
.hero-phone { font-size: 26rpx; color: #84746b; letter-spacing: 1rpx; }
.member-id {
display: flex; align-items: center; gap: 14rpx;
margin-top: 24rpx; min-height: 48rpx;
font-size: 20rpx; color: #88786f;
}
.member-id-label { flex-shrink: 0; }
.member-id-value { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.member-id-copy { flex-shrink: 0; color: #715a4e; padding: 8rpx 0 8rpx 8rpx; }
.time-pair {
display: flex; gap: 20rpx;
margin-top: 20rpx; padding-top: 24rpx;
border-top: 1rpx solid #e4d8d0;
}
.time-cell { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10rpx; }
.time-rule { width: 1rpx; background: #e4d8d0; }
.time-label { font-size: 21rpx; color: #88786f; }
.time-value { font-size: 22rpx; color: #6d5c52; line-height: 1.5; font-variant-numeric: tabular-nums; }
.section { padding: 36rpx 32rpx 0; }
.section--last { padding-bottom: 24rpx; }
.section-label { display: block; font-size: 28rpx; font-weight: 500; margin-bottom: 20rpx; }
.section-heading { display: flex; justify-content: space-between; align-items: baseline; gap: 16rpx; }
.supplement-entry { margin: 0; padding: 8rpx 18rpx; line-height: 1.5; font-size: 23rpx; border-radius: 999rpx; background: #edf2ec; color: #617d73; &::after { border: none; } }
.section-note { font-size: 22rpx; color: var(--muted); }
.stats-strip {
display: flex; padding: 26rpx 0;
border-radius: 24rpx; background: #ffffff;
}
.stat {
flex: 1; min-width: 0; display: flex; flex-direction: column;
align-items: center; gap: 12rpx; border-right: 1rpx solid var(--line);
&:last-child { border-right: none; }
}
.stat-num { font-size: 40rpx; font-weight: 400; line-height: 1.1; font-family: 'DIN Alternate', 'Avenir Next', sans-serif; font-variant-numeric: tabular-nums; }
.stat-name { font-size: 22rpx; color: var(--muted); }
.stat--completed { .stat-num, .stat-name { color: var(--sage); } }
.card-list { display: flex; flex-direction: column; gap: 20rpx; }
.mship { padding: 28rpx; background: #fff; border-radius: 28rpx; border: 1rpx solid var(--line); }
.mship-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 20rpx; }
.mship-titles { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
.mship-name { font-size: 29rpx; font-weight: 500; line-height: 1.5; overflow-wrap: anywhere; }
.mship-type { font-size: 21rpx; color: var(--muted); }
.mship-status {
flex-shrink: 0; padding: 6rpx 16rpx; border-radius: 999rpx;
background: #edf2ee; color: #617d73; line-height: 1.3;
&--expired, &--used_up { background: #f2efec; color: #8b817b; }
}
.mship-status-text { font-size: 21rpx; }
.mship-times { margin-top: 22rpx; display: flex; align-items: baseline; gap: 10rpx; }
.mship-times-num { font-size: 52rpx; font-weight: 400; font-family: 'DIN Alternate', 'Avenir Next', sans-serif; line-height: 1.2; }
.mship-times-unit { font-size: 22rpx; color: var(--muted); }
.mship-duration { display: flex; align-items: baseline; flex-wrap: wrap; gap: 12rpx; margin-top: 24rpx; }
.mship-duration-title { font-size: 28rpx; color: #617d73; }
.progress { margin-top: 18rpx; }
.progress-bar { height: 6rpx; border-radius: 6rpx; background: #f0efea; overflow: hidden; }
.progress-fill { height: 100%; border-radius: 6rpx; background: #a5b8ab; }
.progress-text { display: block; margin-top: 10rpx; font-size: 20rpx; color: var(--muted); }
.mship-dates {
margin-top: 22rpx; padding-top: 18rpx; border-top: 1rpx solid #f3efeb;
display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8rpx 20rpx;
font-size: 21rpx; color: var(--muted); line-height: 1.5;
}
.empty-card {
background: #f4f2ed; border-radius: 28rpx; padding: 36rpx 28rpx;
display: flex; flex-direction: column; align-items: flex-start; gap: 12rpx;
}
.empty-card-title { font-size: 28rpx; font-weight: 500; }
.empty-card-sub { font-size: 23rpx; color: var(--muted); line-height: 1.7; }
.empty-card-btn { margin-top: 10rpx; padding: 14rpx 28rpx; border-radius: 999rpx; background: #e4ebe4; }
.empty-card-btn-text { font-size: 24rpx; color: #526e62; }
.upcoming-list { background: #fff; border-radius: 28rpx; padding: 0 28rpx; }
.upcoming-row {
display: flex; align-items: center; justify-content: space-between; gap: 24rpx;
padding: 26rpx 0; border-bottom: 1rpx solid var(--line);
&:last-child { border-bottom: none; }
}
.upcoming-time { flex-shrink: 0; display: flex; flex-direction: column; gap: 10rpx; }
.upcoming-date { font-size: 28rpx; font-weight: 500; }
.upcoming-hour { font-size: 23rpx; color: var(--muted); font-variant-numeric: tabular-nums; }
.upcoming-meta { min-width: 0; display: flex; flex-direction: column; align-items: flex-end; gap: 10rpx; }
.upcoming-card { font-size: 24rpx; text-align: right; overflow-wrap: anywhere; }
.upcoming-status { font-size: 21rpx; color: var(--sage); }
.upcoming-empty { padding: 32rpx 28rpx; background: #f4f2ed; border-radius: 24rpx; }
.upcoming-empty-text { font-size: 24rpx; color: var(--muted); }
.dock {
position: fixed; z-index: 10; left: 0; right: 0; bottom: 0;
display: flex; gap: 20rpx;
padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom));
background: #fbf9f6; border-top: 1rpx solid var(--line);
}
.dock-btn { flex: 1; height: 88rpx; border-radius: 999rpx; display: flex; align-items: center; justify-content: center; }
.dock-btn--ghost { background: #f0eae4; }
.dock-btn--solid { flex: 1.35; background: #6b8276; }
.dock-btn--disabled { background: #d9dfd8; .dock-btn-text--solid { color: #677467; } }
.dock-btn-text { font-size: 28rpx; font-weight: 500; color: #78675c; }
.dock-btn-text--solid { color: #fff; }
</style>

View File

@@ -0,0 +1,435 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="编辑资料" show-back />
<view v-if="pageLoading" class="loading-wrap">
<text class="loading-text">加载中...</text>
</view>
<view v-else class="form">
<view class="block">
<text class="block-title">基本信息</text>
<view class="field">
<text class="field-label">昵称</text>
<input class="field-input" v-model="profileForm.nickname" maxlength="32" placeholder="会员昵称" />
</view>
<view class="field">
<text class="field-label">手机号</text>
<input class="field-input" v-model="profileForm.phone" maxlength="20" type="number" placeholder="未绑定可手动填写" />
</view>
</view>
<view class="block">
<text class="block-title">{{ editingMembership ? '会员卡' : '开通会员卡' }}</text>
<view v-if="existingMemberships.length > 1" class="field">
<text class="field-label">选择卡片</text>
<picker
class="field-picker"
mode="selector"
:value="membershipIndex"
:range="membershipPickerLabels"
@change="onMembershipPick"
>
<view class="picker-inner">
<text class="picker-text">{{ membershipPickerLabels[membershipIndex] }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<view class="field">
<text class="field-label">卡类型</text>
<picker
class="field-picker"
mode="selector"
:value="editForm.cardTypeIndex"
:range="cardTypes"
range-key="name"
@change="onCardTypeChange"
>
<view class="picker-inner">
<text class="picker-text">{{ cardTypes[editForm.cardTypeIndex]?.name || '请选择' }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<view v-if="selectedCardType" class="field">
<text class="field-label">
{{ canLeaveTimesEmpty ? '剩余次数(留空不限次)' : '剩余次数' }}
</text>
<input
class="field-input"
type="number"
v-model="editForm.remainingTimes"
:placeholder="canLeaveTimesEmpty ? '留空表示不限次' : '请输入剩余次数'"
/>
</view>
<view class="field">
<text class="field-label">开始日期</text>
<picker class="field-picker" mode="date" :value="editForm.startDate" @change="onStartDateChange">
<view class="picker-inner">
<text class="picker-text">{{ editForm.startDate || '请选择' }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<view class="field">
<text class="field-label">到期日期</text>
<picker class="field-picker" mode="date" :value="editForm.expireDate" @change="onExpireDateChange">
<view class="picker-inner">
<text class="picker-text">{{ editForm.expireDate || '请选择' }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<view v-if="editingMembership" class="danger-link" @tap="onClearMembership">
<text class="danger-link-text">解除该会员卡</text>
</view>
</view>
<view
class="save-btn"
:class="{ 'save-btn--disabled': submitting }"
@tap="onSave"
>
<text class="save-btn-text">{{ submitting ? '保存中...' : '保存' }}</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import type { AdminMemberDetail, CardType, MembershipWithCardType } from '@mp-pilates/shared'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { formatDateLocal } from '../../utils/format'
import { getErrorMessage } from '../../utils/auth'
import { useAdminStore } from '../../stores/admin'
const adminStore = useAdminStore()
const navBarHeight = ref('64px')
const userId = ref('')
const pageLoading = ref(true)
const submitting = ref(false)
const detail = ref<AdminMemberDetail | null>(null)
const cardTypes = ref<CardType[]>([])
const membershipIndex = ref(0)
const profileForm = ref({
nickname: '',
phone: '',
})
const editForm = ref({
membershipId: '' as string | '',
cardTypeIndex: 0,
cardTypeId: '',
remainingTimes: null as number | string | null,
startDate: '',
expireDate: '',
manuallyEditedExpire: false,
})
const existingMemberships = computed(() => detail.value?.memberships ?? [])
const editingMembership = computed(() => existingMemberships.value[membershipIndex.value] ?? null)
const membershipPickerLabels = computed(() =>
existingMemberships.value.map((item) => `${item.cardType.name} · ${item.status}`),
)
const selectedCardType = computed(() => cardTypes.value[editForm.value.cardTypeIndex] ?? null)
const canLeaveTimesEmpty = computed(() => {
const cardType = selectedCardType.value
const membership = editingMembership.value
if (!cardType || cardType.type !== 'DURATION') return false
return cardType.totalTimes === null || (
membership?.cardTypeId === cardType.id && membership.remainingTimes === null
)
})
function calculateExpireDate(startDate: string, durationDays: number): string {
const d = new Date(startDate)
d.setDate(d.getDate() + durationDays)
return formatDateLocal(d)
}
function applyMembership(membership: MembershipWithCardType | null) {
const types = cardTypes.value
if (membership) {
const idx = types.findIndex((item) => item.id === membership.cardTypeId)
editForm.value = {
membershipId: membership.id,
cardTypeIndex: idx >= 0 ? idx : 0,
cardTypeId: membership.cardTypeId,
remainingTimes: membership.remainingTimes,
startDate: membership.startDate.slice(0, 10),
expireDate: membership.expireDate.slice(0, 10),
manuallyEditedExpire: false,
}
return
}
editForm.value = {
membershipId: '',
cardTypeIndex: 0,
cardTypeId: types[0]?.id || '',
remainingTimes: types[0]?.totalTimes ?? null,
startDate: formatDateLocal(new Date()),
expireDate: calculateExpireDate(formatDateLocal(new Date()), types[0]?.durationDays ?? 30),
manuallyEditedExpire: false,
}
}
function onMembershipPick(e: { detail: { value: number } }) {
membershipIndex.value = Number(e.detail.value)
applyMembership(existingMemberships.value[membershipIndex.value] ?? null)
}
function onCardTypeChange(e: { detail: { value: number } }) {
const idx = Number(e.detail.value)
const cardType = cardTypes.value[idx]
if (!cardType) return
editForm.value.cardTypeIndex = idx
editForm.value.cardTypeId = cardType.id
editForm.value.remainingTimes = cardType.totalTimes
if (!editForm.value.manuallyEditedExpire) {
editForm.value.startDate = formatDateLocal(new Date())
editForm.value.expireDate = calculateExpireDate(formatDateLocal(new Date()), cardType.durationDays)
}
}
function onStartDateChange(e: { detail: { value: string } }) {
editForm.value.startDate = e.detail.value
if (!editForm.value.manuallyEditedExpire) {
const cardType = cardTypes.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 parseRemainingTimes(): number | null | undefined {
const rawValue = editForm.value.remainingTimes
if (rawValue === null || (typeof rawValue === 'string' && rawValue.trim() === '')) {
return null
}
const remainingTimes = Number(rawValue)
if (!Number.isInteger(remainingTimes) || remainingTimes < 0) return undefined
return remainingTimes
}
async function loadPage() {
pageLoading.value = true
try {
const [member, types] = await Promise.all([
adminStore.fetchMemberDetail(userId.value),
cardTypes.value.length ? Promise.resolve(cardTypes.value) : adminStore.fetchCardTypes(),
])
detail.value = member
cardTypes.value = [...types]
profileForm.value = {
nickname: member.user.nickname || '',
phone: member.user.phone || '',
}
membershipIndex.value = 0
applyMembership(member.memberships[0] ?? null)
} catch (err: unknown) {
uni.showToast({ title: getErrorMessage(err, '加载失败'), icon: 'none' })
} finally {
pageLoading.value = false
}
}
async function onSave() {
if (submitting.value || !userId.value) return
if (!editForm.value.cardTypeId) {
uni.showToast({ title: '请选择卡类型', icon: 'none' })
return
}
const remainingTimes = parseRemainingTimes()
if (remainingTimes === undefined) {
uni.showToast({ title: '剩余次数需为非负整数', icon: 'none' })
return
}
if (remainingTimes === null && !canLeaveTimesEmpty.value) {
uni.showToast({ title: '请输入剩余次数', icon: 'none' })
return
}
submitting.value = true
try {
await adminStore.updateMemberProfile(userId.value, {
nickname: profileForm.value.nickname.trim(),
phone: profileForm.value.phone.trim(),
})
await adminStore.updateUserMembership(userId.value, {
...(editForm.value.membershipId ? { membershipId: editForm.value.membershipId } : {}),
cardTypeId: editForm.value.cardTypeId,
remainingTimes,
startDate: editForm.value.startDate,
expireDate: editForm.value.expireDate,
})
uni.showToast({ title: '保存成功', icon: 'success' })
setTimeout(() => uni.navigateBack(), 400)
} catch (err: unknown) {
uni.showToast({ title: getErrorMessage(err, '保存失败'), icon: 'none' })
} finally {
submitting.value = false
}
}
function onClearMembership() {
if (!userId.value) return
uni.showModal({
title: '确认解除',
content: '确定要解除当前这张会员卡吗?其他卡不受影响。',
confirmColor: '#C47A7A',
success: async (res) => {
if (!res.confirm) return
const membershipId = editForm.value.membershipId
if (!membershipId) {
uni.showToast({ title: '没有可解除的会员卡', icon: 'none' })
return
}
try {
await adminStore.deleteUserMembership(userId.value, membershipId)
uni.showToast({ title: '已解除', icon: 'success' })
setTimeout(() => uni.navigateBack(), 400)
} catch (err: unknown) {
uni.showToast({ title: getErrorMessage(err, '操作失败'), icon: 'none' })
}
},
})
}
onLoad((query) => {
userId.value = String(query?.userId || '')
})
onMounted(() => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
loadPage()
})
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: $bg-page;
padding-bottom: 80rpx;
}
.loading-wrap {
padding: 80rpx 0;
text-align: center;
}
.loading-text {
font-size: 26rpx;
color: $text-hint;
}
.form {
padding: 24rpx;
}
.block {
background: $bg-card;
border-radius: 20rpx;
padding: 8rpx 24rpx 16rpx;
margin-bottom: 20rpx;
border: 1rpx solid rgba(180, 160, 130, 0.12);
}
.block-title {
display: block;
padding: 20rpx 0 8rpx;
font-size: 22rpx;
letter-spacing: 3rpx;
color: $text-hint;
}
.field {
padding: 20rpx 0;
border-bottom: 1rpx solid rgba(180, 160, 130, 0.1);
&:last-of-type { border-bottom: none; }
}
.field-label {
display: block;
font-size: 24rpx;
color: $text-hint;
margin-bottom: 12rpx;
}
.field-input {
height: 64rpx;
font-size: 30rpx;
color: $text-primary;
}
.field-picker {
display: block;
}
.picker-inner {
display: flex;
align-items: center;
justify-content: space-between;
height: 64rpx;
}
.picker-text {
font-size: 30rpx;
color: $text-primary;
}
.picker-arrow {
font-size: 24rpx;
color: $text-hint;
}
.danger-link {
padding: 20rpx 0 8rpx;
}
.danger-link-text {
font-size: 24rpx;
color: $error-color;
}
.save-btn {
margin-top: 12rpx;
height: 88rpx;
border-radius: 18rpx;
background: $brand-color;
display: flex;
align-items: center;
justify-content: center;
}
.save-btn--disabled { opacity: 0.5; }
.save-btn-text {
font-size: 30rpx;
font-weight: 700;
color: #fff8f0;
}
</style>

View File

@@ -0,0 +1,211 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="补录上课" show-back />
<view v-if="loading && !detail" class="state">正在读取会员资料</view>
<view v-else-if="!detail" class="state"><text>{{ loadError || '暂时无法读取会员资料' }}</text><button @tap="load">重新加载</button></view>
<template v-else>
<view v-if="loadError" class="pending-note">{{ loadError }}<button class="reload" :disabled="loading || busy" @tap="load">刷新资料</button></view>
<view class="intro">
<text class="eyebrow">历史课程</text>
<text class="title">补齐每一节练习</text>
<text class="intro-note"> {{ detail.user.nickname || '这位学员' }} 补录已经上完的课程</text>
<view class="current"><text>累计上课</text><text>{{ detail.stats.completedBookings }} </text></view>
</view>
<view v-if="pending" class="pending-note">有一笔补录正在核对请点击下方重试系统会避免重复计入</view>
<view class="form-card">
<text class="label">补录节数</text>
<view class="quantity-row">
<input v-model="quantity" class="quantity-input" type="number" maxlength="3" placeholder="0" :disabled="busy || !!pending" :cursor-spacing="24" />
<text class="unit">节已上课程</text>
</view>
<view class="quick-values"><button v-for="n in [1, 5, 10, 20]" :key="n" :class="{ selected: Number(quantity) === n }" :disabled="busy || !!pending" @tap="quantity = String(n)">{{ n }} </button></view>
<text class="hint">可填写 1999 无需逐节创建预约</text>
</view>
<view class="form-card">
<text class="label">会员卡次数</text>
<view class="options">
<button class="option" :class="{ chosen: !deduct }" :disabled="busy || !!pending" @tap="deduct = false"><text class="option-title">仅补记录</text><text class="option-note">不改变会员卡余额</text></button>
<button class="option" :class="{ chosen: deduct }" :disabled="busy || !!pending" @tap="deduct = true"><text class="option-title">同时扣次</text><text class="option-note">从指定会员卡扣除</text></button>
</view>
<template v-if="deduct">
<text v-if="!cards.length" class="field-error">暂无可扣次的会员卡请选择仅补记录</text>
<picker v-else :range="cardLabels" :value="cardIndex" :disabled="busy || !!pending" @change="cardIndex = Number($event.detail.value)">
<view class="card-picker"><text>{{ cardLabels[cardIndex] || '请选择会员卡' }}</text><text></text></view>
</picker>
<text v-if="selectedCard && validQuantity" class="balance" :class="{ 'field-error': insufficient }">{{ insufficient ? '余额不足请调整节数或更换会员卡' : `扣除 ${count} 次后剩余 ${selectedCard.remainingTimes! - count} ` }}</text>
<text class="hint">过去已扣过的课程请选择仅补记录不限次卡无需扣次</text>
</template>
</view>
<view class="form-card">
<view class="label-row"><text class="label">备注</text><text class="optional">选填</text></view>
<textarea v-model="remark" class="remark" maxlength="200" placeholder="例如:系统启用前已完成的 10 节私教课" :disabled="busy || !!pending" :cursor-spacing="32" />
<text class="hint">{{ remark.length }} / 200</text>
</view>
<view class="summary">
<text class="summary-title">{{ validQuantity ? `累计上课将增加 ${count}` : '填写已完成的课程节数' }}</text>
<text class="hint">记录显示为系统补录不计入本月上课练习天数与最近 30 天网格</text>
</view>
<view class="submit-wrap"><button class="submit" :disabled="busy || loading || !!loadError || (!pending && !canSubmit)" :loading="busy" @tap="submit">{{ busy ? '正在处理' : pending ? '核对并重试补录' : '确认补录' }}</button><text v-if="submitError" class="field-error">{{ submitError }}</text></view>
<view class="history">
<view class="label-row"><text class="label">补录记录</text><text class="optional">{{ records.length }} </text></view>
<LessonSupplementList v-if="records.length" :records="records" editable :busy="busy || !!pending" @revoke="revoke" />
<text v-else class="empty">还没有补录记录</text>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import type { AdminMemberDetail, CreateLessonSupplementDto, LessonSupplementRecord } from '@mp-pilates/shared'
import CustomNavBar from '../../components/CustomNavBar.vue'
import LessonSupplementList from '../../components/LessonSupplementList.vue'
import { useAdminStore } from '../../stores/admin'
import { getSystemLayout } from '../../utils/system'
import { HttpRequestError } from '../../utils/request'
import { getErrorMessage } from '../../utils/auth'
const store = useAdminStore()
const navBarHeight = `${getSystemLayout().navBarHeight}px`
const userId = ref('')
const detail = ref<AdminMemberDetail | null>(null)
const records = ref<LessonSupplementRecord[]>([])
const loading = ref(false)
const busy = ref(false)
const loadError = ref('')
const submitError = ref('')
const quantity = ref('')
const remark = ref('')
const deduct = ref(false)
const cardIndex = ref(0)
const pending = ref<CreateLessonSupplementDto | null>(null)
const storageKey = computed(() => `lesson-supplement-pending:${userId.value}`)
const cards = computed(() => (detail.value?.memberships ?? []).filter(c => c.remainingTimes !== null && c.remainingTimes > 0))
const cardLabels = computed(() => cards.value.map(c => `${c.cardType.name} · 余 ${c.remainingTimes}${new Date(c.expireDate) <= new Date() || c.status === 'EXPIRED' ? ' · 已过期' : ''}`))
const selectedCard = computed(() => cards.value[cardIndex.value])
const count = computed(() => Number(quantity.value))
const validQuantity = computed(() => /^\d{1,3}$/.test(quantity.value) && count.value >= 1 && count.value <= 999)
const insufficient = computed(() => !!selectedCard.value && count.value > selectedCard.value.remainingTimes!)
const canSubmit = computed(() => validQuantity.value && (!deduct.value || (!!selectedCard.value && !insufficient.value)))
function clearPending() {
pending.value = null
uni.removeStorageSync(storageKey.value)
}
async function load() {
if (!userId.value) { loadError.value = '缺少会员信息,请返回会员档案'; return }
loading.value = true
loadError.value = ''
try {
const [member, items] = await Promise.all([store.fetchMemberDetail(userId.value), store.fetchLessonSupplements(userId.value)])
detail.value = member
records.value = items
if (pending.value && items.some(item => item.requestId === pending.value!.requestId)) {
clearPending()
quantity.value = ''
remark.value = ''
submitError.value = ''
}
} catch (err) {
loadError.value = getErrorMessage(err, '加载失败,请重试')
if (detail.value) uni.showToast({ title: loadError.value, icon: 'none' })
} finally { loading.value = false }
}
async function submit() {
if (busy.value || loading.value || loadError.value || (!pending.value && !canSubmit.value)) return
busy.value = true
submitError.value = ''
try {
if (!pending.value) {
const result = await uni.showModal({
title: `补录 ${count.value} 节已上课程`,
content: `学员:${detail.value?.user.nickname || '未命名学员'}${deduct.value ? `从「${selectedCard.value.cardType.name}」扣除 ${count.value} 次,剩余 ${selectedCard.value.remainingTimes! - count.value} 次。` : '仅增加上课记录,不扣会员卡次数。'}`,
confirmText: '确认补录', confirmColor: '#617d73',
})
if (!result.confirm) return
const payload: CreateLessonSupplementDto = {
requestId: `supp_${Date.now()}_${Math.random().toString(36).slice(2)}_${Math.random().toString(36).slice(2)}`,
quantity: count.value, ...(deduct.value ? { membershipId: selectedCard.value.id } : {}),
...(remark.value.trim() ? { remark: remark.value.trim() } : {}),
}
// Retain the exact request across timeouts, navigation and application restarts.
uni.setStorageSync(storageKey.value, payload)
pending.value = payload
}
const record = await store.createLessonSupplement(userId.value, pending.value)
if (!records.value.some(item => item.id === record.id)) records.value = [record, ...records.value]
clearPending()
quantity.value = ''
remark.value = ''
uni.showToast({ title: `已补录 ${record.quantity} 节课`, icon: 'success' })
await load()
} catch (err) {
if (err instanceof HttpRequestError && err.statusCode >= 400 && err.statusCode < 500 && err.statusCode !== 408) clearPending()
submitError.value = getErrorMessage(err, '提交失败,请重试')
if (pending.value) submitError.value = '暂未确认提交结果,请核对并重试,不会重复补录。'
} finally { busy.value = false }
}
async function revoke(item: LessonSupplementRecord) {
if (busy.value || pending.value) return
busy.value = true
try {
const result = await uni.showModal({ title: '撤销这笔补录?', content: `累计上课减少 ${item.quantity} 节。${item.deductedTimes ? `向原会员卡返还 ${item.deductedTimes} 次。` : '会员卡余额不变。'}记录会保留为已撤销。`, confirmText: '确认撤销', confirmColor: '#967561' })
if (!result.confirm) return
await store.revokeLessonSupplement(userId.value, item.id)
uni.showToast({ title: '已撤销补录', icon: 'success' })
await load()
} catch (err) { uni.showToast({ title: getErrorMessage(err, '撤销失败,请重试'), icon: 'none' }) }
finally { busy.value = false }
}
onLoad(query => {
userId.value = String(query?.userId || '')
const saved = uni.getStorageSync(storageKey.value) as CreateLessonSupplementDto | undefined
if (saved?.requestId && Number.isInteger(saved.quantity)) {
pending.value = saved
quantity.value = String(saved.quantity)
remark.value = saved.remark || ''
deduct.value = !!saved.membershipId
}
load().then(() => {
if (pending.value?.membershipId) cardIndex.value = cards.value.findIndex(c => c.id === pending.value!.membershipId)
})
})
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; box-sizing: border-box; background: #fbf9f6; color: #514943; padding-bottom: calc(40rpx + env(safe-area-inset-bottom)); }
.intro { margin: 28rpx 32rpx; padding: 32rpx; border-radius: 30rpx; background: #f2e9e3; }
.eyebrow { display: block; font-size: 21rpx; color: #967c69; }
.title { display: block; margin-top: 14rpx; font-size: 38rpx; font-family: 'Songti SC', 'STSong', serif; }
.intro-note { display: block; margin-top: 16rpx; font-size: 24rpx; line-height: 1.7; color: #87786b; overflow-wrap: anywhere; }
.current { display: flex; justify-content: space-between; gap: 20rpx; margin-top: 26rpx; padding-top: 22rpx; border-top: 1rpx solid #e4d8cf; font-size: 24rpx; color: #78675c; }
.form-card { margin: 24rpx 32rpx; padding: 28rpx; border-radius: 26rpx; border: 1rpx solid #eee8e3; background: #fff; }
.label { font-size: 27rpx; font-weight: 500; }
.label-row { display: flex; align-items: center; justify-content: space-between; gap: 20rpx; margin-bottom: 20rpx; }
.optional { font-size: 21rpx; color: #9a9086; }
.quantity-row { display: flex; align-items: baseline; gap: 20rpx; margin-top: 20rpx; }
.quantity-input { width: 180rpx; min-width: 0; height: 92rpx; font-size: 64rpx; color: #617d73; font-variant-numeric: tabular-nums; }
.unit { font-size: 24rpx; color: #8b817b; }
.quick-values { display: flex; gap: 14rpx; margin-top: 20rpx; button { flex: 1; padding: 0; margin: 0; border-radius: 14rpx; font-size: 24rpx; line-height: 64rpx; background: #f5f3ef; color: #81756b; &::after { border: none; } &.selected { background: #e8efe7; color: #617d73; } } }
.hint { display: block; margin-top: 18rpx; color: #92867b; font-size: 22rpx; line-height: 1.7; }
.options { display: flex; gap: 16rpx; margin-top: 22rpx; }
.option { flex: 1; min-width: 0; margin: 0; padding: 22rpx 12rpx; text-align: left; border-radius: 18rpx; border: 2rpx solid #eee8e3; background: #fff; line-height: 1.5; &::after { border: none; } &.chosen { border-color: #a6b8a9; background: #eff3ed; } }
.option-title { display: block; font-size: 26rpx; color: #617d73; }
.option-note { display: block; margin-top: 8rpx; font-size: 20rpx; color: #8b817b; }
.card-picker { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; margin-top: 22rpx; padding: 20rpx; border-radius: 16rpx; background: #f6f3ef; font-size: 24rpx; line-height: 1.6; }
.balance { display: block; margin-top: 16rpx; font-size: 23rpx; color: #617d73; }
.remark { width: 100%; height: 136rpx; box-sizing: border-box; padding: 18rpx; border-radius: 16rpx; background: #faf8f5; font-size: 25rpx; line-height: 1.6; }
.summary { margin: 28rpx 40rpx; }
.summary-title { font-size: 26rpx; color: #617d73; }
.submit-wrap { margin: 28rpx 32rpx; }
.submit { display: block; width: 100%; padding: 0; margin: 0; line-height: 88rpx; border-radius: 999rpx; background: #6b8276; color: #fff; font-size: 28rpx; &::after { border: none; } &[disabled] { background: #e3e8df; color: #8a9586; } }
.history { margin: 44rpx 32rpx 0; }
.empty { display: block; padding: 36rpx 28rpx; border-radius: 24rpx; background: #f3f0eb; font-size: 24rpx; color: #8b817b; }
.field-error { display: block; margin-top: 18rpx; color: #a36e59; font-size: 23rpx; line-height: 1.6; }
.pending-note { margin: 24rpx 32rpx; padding: 24rpx; background: #f2e9e3; color: #8b7160; border-radius: 20rpx; font-size: 24rpx; line-height: 1.7; }
.reload { margin: 16rpx 0 0; font-size: 24rpx; line-height: 2; background: #e5eade; color: #617d73; &::after { border: none; } }
.state { padding: 60rpx 32rpx; font-size: 26rpx; color: #8b817b; text-align: center; button { margin-top: 24rpx; font-size: 26rpx; } }
</style>

View File

@@ -1,40 +1,57 @@
<template>
<view class="page">
<!-- Search bar -->
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="会员管理" show-back />
<view class="filter-bar">
<view class="search-field">
<input
class="search-input"
v-model="searchQuery"
placeholder="搜索昵称或手机号"
placeholder="搜索昵称 / 手机 / OpenID"
placeholder-style="color:#bbb"
@confirm="onSearch"
confirm-type="search"
/>
<view v-if="searchQuery" class="search-clear" @tap="onClear">
<text class="search-clear-icon">×</text>
</view>
</view>
<picker
class="type-picker"
mode="selector"
:value="cardTypeIndex"
:range="cardTypeOptions"
range-key="label"
@change="onCardTypeChange"
>
<view class="type-picker-inner">
<text class="type-picker-text">{{ cardTypeOptions[cardTypeIndex].label }}</text>
<text class="type-picker-arrow"></text>
</view>
</picker>
<view class="search-btn" @tap="onSearch">
<text class="search-btn-text">搜索</text>
</view>
</view>
<!-- Stats row -->
<view class="stats-row">
<view class="stat-item">
<text class="stat-value">{{ total }}</text>
<text class="stat-label">总会员</text>
<text class="stat-label">位用户</text>
</view>
</view>
<!-- Loading skeleton -->
<view v-if="loading && !members.length" class="skeleton-list">
<view v-for="i in 5" :key="i" class="skeleton-item" />
</view>
<!-- Empty -->
<view v-else-if="!loading && !members.length" class="empty-state">
<text class="empty-icon">👥</text>
<text class="empty-text">暂无会员数据</text>
<view class="empty-icon-wrap">
<view class="empty-icon-person" />
</view>
<text class="empty-text">{{ searchQuery ? '未找到匹配的用户' : '当前筛选下暂无用户' }}</text>
</view>
<!-- Member list -->
<view v-else class="member-list">
<view
v-for="m in members"
@@ -49,8 +66,20 @@
</view>
</view>
<view class="member-info">
<text class="member-name">{{ m.nickname || '未知用户' }}</text>
<text class="member-phone">{{ m.phone || '未绑定手机' }}</text>
<view class="member-name-row">
<text class="member-name">{{ m.nickname || '未知用户' }}</text>
<view
v-if="m.activeCard"
class="card-chip"
:class="'card-chip--' + m.activeCard.type.toLowerCase()"
>
<text class="card-chip-text">{{ getCardTypeLabel(m.activeCard.type) }}</text>
</view>
<view v-else class="card-chip card-chip--none">
<text class="card-chip-text">无卡</text>
</view>
</view>
<text class="member-sub">{{ m.phone || m.openid }}</text>
</view>
<view class="member-stats">
<text class="member-stat-value">{{ m.totalBookings }}</text>
@@ -60,90 +89,125 @@
</view>
</view>
<!-- Load more -->
<view v-if="hasMore" class="load-more" @tap="loadMore">
<text class="load-more-text">{{ loading ? '加载中...' : '加载更多' }}</text>
</view>
<!-- Detail modal -->
<view v-if="showDetail && detailMember" class="modal-mask" @tap.self="showDetail = false">
<view class="modal">
<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-phone">{{ detailMember.phone || '未绑定手机' }}</text>
</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 class="modal-close" @tap="showDetail = false">
<text class="modal-close-text">关闭</text>
</view>
</view>
<view v-if="members.length" class="list-footer">
<text class="list-footer-text">
{{ loading ? '加载中...' : hasMore ? '上拉加载更多' : '— 已加载全部 —' }}
</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, onUnmounted } from 'vue'
import { onReachBottom, onShow } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { getCardTypeLabel } from '../../utils/format'
import { useAdminStore } from '../../stores/admin'
import type { MemberSummary } from '../../stores/admin'
const adminStore = useAdminStore()
const navBarHeight = ref('64px')
const members = ref<MemberSummary[]>([])
const loading = ref(false)
const searchQuery = ref('')
const page = ref(1)
const total = ref(0)
const hasMore = ref(false)
const showDetail = ref(false)
const detailMember = ref<MemberSummary | null>(null)
const LIMIT = 20
const cardTypeOptions = [
{ label: '会员用户', value: 'ACTIVE' },
{ label: '全部用户', value: '' },
{ label: '体验卡', value: 'TRIAL' },
{ label: '次卡', value: 'TIMES' },
{ label: '月卡', value: 'DURATION' },
{ label: '无卡用户', value: 'NONE' },
]
const cardTypeIndex = ref(0)
let requestId = 0
let cardTypeDebounceTimer: ReturnType<typeof setTimeout> | null = null
function onCardTypeChange(e: { detail: { value: number } }) {
cardTypeIndex.value = Number(e.detail.value)
// Invalidate the old filter immediately, including during the debounce window.
requestId++
loading.value = true
members.value = []
total.value = 0
hasMore.value = false
if (cardTypeDebounceTimer) clearTimeout(cardTypeDebounceTimer)
cardTypeDebounceTimer = setTimeout(() => {
loadMembers(true)
cardTypeDebounceTimer = null
}, 300)
}
onUnmounted(() => {
requestId++
if (cardTypeDebounceTimer) clearTimeout(cardTypeDebounceTimer)
})
async function loadMembers(reset = false) {
if (loading.value) return
if (loading.value && !reset) return
const id = ++requestId
const requestedPage = reset ? 1 : page.value + 1
if (reset) {
page.value = 1
members.value = []
total.value = 0
hasMore.value = false
}
loading.value = true
try {
const search = searchQuery.value.trim()
const cardType = cardTypeOptions[cardTypeIndex.value].value
const result = await adminStore.fetchMembers({
page: page.value,
page: requestedPage,
limit: LIMIT,
search: searchQuery.value.trim() || undefined,
...(search ? { search } : {}),
...(cardType ? { cardType } : {}),
})
if (id !== requestId) return
page.value = requestedPage
if (reset) {
members.value = [...result.items]
} else {
members.value.push(...result.items)
members.value = [...members.value, ...result.items]
}
total.value = result.total
hasMore.value = members.value.length < result.total
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
if (id === requestId) uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
if (id === requestId) loading.value = false
}
}
async function refreshVisibleMembers() {
if (loading.value) return
const id = ++requestId
loading.value = true
try {
const search = searchQuery.value.trim()
const cardType = cardTypeOptions[cardTypeIndex.value].value
const limit = Math.max(members.value.length, LIMIT)
const result = await adminStore.fetchMembers({
page: 1,
limit,
...(search ? { search } : {}),
...(cardType ? { cardType } : {}),
})
if (id !== requestId) return
members.value = [...result.items]
total.value = result.total
page.value = Math.max(1, Math.ceil(members.value.length / LIMIT) || 1)
hasMore.value = members.value.length < result.total
} catch {
if (id === requestId) uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
if (id === requestId) loading.value = false
}
}
@@ -151,106 +215,205 @@ function onSearch() {
loadMembers(true)
}
function loadMore() {
if (!hasMore.value || loading.value) return
page.value++
loadMembers(false)
function onClear() {
searchQuery.value = ''
loadMembers(true)
}
onReachBottom(() => {
if (!hasMore.value || loading.value) return
loadMembers(false)
})
function openDetail(m: MemberSummary) {
detailMember.value = m
showDetail.value = true
uni.navigateTo({ url: `/pages/admin/member-detail?userId=${m.userId}` })
}
onMounted(() => loadMembers(true))
let hasLoaded = false
onMounted(() => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
loadMembers(true).then(() => {
hasLoaded = true
})
})
onShow(() => {
if (hasLoaded) {
refreshVisibleMembers()
}
})
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #f5f3f0;
background: $bg-page;
padding-bottom: 40rpx;
}
/* ── Filter bar ──────────────────────────── */
.filter-bar {
display: flex;
align-items: center;
gap: 16rpx;
padding: 24rpx;
background: #ffffff;
border-bottom: 1rpx solid #eee;
background: $bg-card;
border-bottom: 1rpx solid $border-color;
position: relative;
}
.search-field {
flex: 1;
min-width: 0;
position: relative;
}
.search-input {
flex: 1;
min-width: 0;
height: 72rpx;
background: #f5f3f0;
background: $bg-page;
border-radius: 36rpx;
padding: 0 28rpx;
padding: 0 60rpx 0 24rpx;
font-size: 26rpx;
color: #333;
color: $text-primary;
}
.search-clear {
position: absolute;
right: 12rpx;
top: 14rpx;
width: 44rpx;
height: 44rpx;
display: flex;
align-items: center;
justify-content: center;
}
.search-clear-icon {
font-size: 32rpx;
color: $text-hint;
line-height: 1;
}
.search-btn {
background: #1a1a2e;
background: $brand-color;
border-radius: 36rpx;
padding: 16rpx 32rpx;
}
.search-btn-text { font-size: 26rpx; font-weight: 600; color: #c9a87c; }
.search-btn-text { font-size: 26rpx; font-weight: 600; color: $accent-color; }
.type-picker {
flex-shrink: 0;
background: $bg-page;
border-radius: 36rpx;
padding: 0 20rpx;
height: 72rpx;
display: flex;
align-items: center;
}
.type-picker-inner {
display: flex;
align-items: center;
gap: 8rpx;
}
.type-picker-text {
font-size: 24rpx;
color: $text-secondary;
max-width: 120rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.type-picker-arrow {
font-size: 20rpx;
color: $text-hint;
}
/* ── Stats row ───────────────────────────── */
.stats-row {
display: flex;
padding: 24rpx 28rpx 16rpx;
}
.stat-item { display: flex; align-items: baseline; gap: 8rpx; }
.stat-value { font-size: 36rpx; font-weight: 800; color: #c9a87c; }
.stat-label { font-size: 24rpx; color: #999; }
.stat-value { font-size: 36rpx; font-weight: 800; color: $accent-color; }
.stat-label { font-size: 24rpx; color: $text-hint; }
/* ── Skeleton ────────────────────────────── */
.skeleton-list { padding: 0 24rpx; }
.skeleton-item {
height: 100rpx;
border-radius: 16rpx;
height: 120rpx;
border-radius: $radius-md;
margin-bottom: 16rpx;
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;
}
@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;
padding: 120rpx 0;
gap: 24rpx;
}
.empty-icon { font-size: 80rpx; }
.empty-text { font-size: 28rpx; color: #bbb; }
.empty-icon-wrap {
width: 96rpx;
height: 96rpx;
border-radius: 50%;
background: rgba($brand-color, 0.06);
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.empty-icon-person {
&::before {
content: '';
width: 20rpx;
height: 20rpx;
border: 3rpx solid $text-hint;
border-radius: 50%;
position: absolute;
top: 22rpx;
left: 50%;
transform: translateX(-50%);
box-sizing: border-box;
}
&::after {
content: '';
width: 36rpx;
height: 16rpx;
border: 3rpx solid $text-hint;
border-bottom: none;
border-radius: 20rpx 20rpx 0 0;
position: absolute;
bottom: 20rpx;
left: 50%;
transform: translateX(-50%);
box-sizing: border-box;
}
}
.empty-text { font-size: 28rpx; color: $text-hint; }
/* ── Member list ─────────────────────────── */
.member-list { padding: 0 24rpx; padding-top: 8rpx; }
.member-row {
background: #ffffff;
border-radius: 16rpx;
background: $bg-card;
border-radius: $radius-md;
padding: 24rpx;
display: flex;
align-items: center;
gap: 20rpx;
margin-bottom: 16rpx;
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.05);
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
}
.member-avatar {
@@ -267,100 +430,101 @@ onMounted(() => loadMembers(true))
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background: #1a1a2e;
background: $brand-color;
display: flex;
align-items: center;
justify-content: center;
}
.avatar-placeholder--lg {
width: 120rpx;
height: 120rpx;
}
.avatar-text {
font-size: 32rpx;
font-size: 28rpx;
font-weight: 700;
color: #c9a87c;
color: $primary-dark;
}
.avatar-text--lg { font-size: 48rpx; }
.member-info { flex: 1; display: flex; flex-direction: column; gap: 8rpx; }
.member-name { font-size: 28rpx; font-weight: 600; color: #1a1a2e; }
.member-phone { font-size: 22rpx; color: #999; }
.member-stats { display: flex; flex-direction: column; align-items: flex-end; gap: 4rpx; }
.member-stat-value { font-size: 32rpx; font-weight: 700; color: #c9a87c; }
.member-stat-label { font-size: 20rpx; color: #bbb; }
.member-arrow { font-size: 36rpx; color: #ccc; }
/* ── Load more ───────────────────────────── */
.load-more {
text-align: center;
padding: 32rpx;
}
.load-more-text { font-size: 26rpx; color: #c9a87c; }
/* ── Detail 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: 48rpx 32rpx 60rpx;
}
.detail-header {
.member-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
margin-bottom: 40rpx;
gap: 8rpx;
}
.detail-avatar {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
.member-name-row {
display: flex;
align-items: center;
gap: 12rpx;
min-width: 0;
}
.member-name {
font-size: 30rpx;
font-weight: 700;
color: $text-primary;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 280rpx;
}
.detail-name { font-size: 32rpx; font-weight: 700; color: #1a1a2e; }
.detail-phone { font-size: 26rpx; color: #888; }
.card-chip {
flex-shrink: 0;
padding: 2rpx 12rpx;
border-radius: 8rpx;
background: rgba($accent-color, 0.16);
.detail-stats {
&--trial { background: rgba(#C47A7A, 0.14); }
&--times { background: rgba($accent-color, 0.16); }
&--duration { background: rgba(#7A9E7E, 0.16); }
&--none { background: rgba($text-hint, 0.12); }
}
.card-chip-text {
font-size: 20rpx;
color: $text-secondary;
letter-spacing: 1rpx;
}
.member-sub {
font-size: 22rpx;
color: $text-hint;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.member-stats {
display: flex;
justify-content: space-around;
background: #f5f3f0;
border-radius: 16rpx;
padding: 28rpx;
margin-bottom: 32rpx;
flex-direction: column;
align-items: flex-end;
flex-shrink: 0;
}
.detail-stat { display: flex; flex-direction: column; align-items: center; gap: 8rpx; }
.detail-stat-value { font-size: 40rpx; font-weight: 800; color: #c9a87c; }
.detail-stat-label { font-size: 22rpx; color: #999; }
.modal-close {
width: 100%;
height: 88rpx;
background: #f0f0f0;
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
.member-stat-value {
font-size: 32rpx;
font-weight: 800;
color: $text-primary;
font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif;
}
.modal-close-text { font-size: 28rpx; color: #555; }
.member-stat-label {
font-size: 20rpx;
color: $text-hint;
}
.member-arrow {
font-size: 32rpx;
color: $text-hint;
flex-shrink: 0;
}
.list-footer {
padding: 16rpx 0 40rpx;
text-align: center;
}
.list-footer-text {
font-size: 22rpx;
color: $text-hint;
}
</style>

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,763 @@
<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="multiSelector"
:range="timePickerRange"
:value="timeToPickerIndex(slot.startTime)"
@change="(e: any) => updateSlotTime(slot, 'startTime', pickerIndexToTime(e.detail.value))"
>
<view class="time-display">
<text class="time-text">{{ slot.startTime }}</text>
</view>
</picker>
<text class="time-separator"></text>
<picker
mode="multiSelector"
:range="timePickerRange"
:value="timeToPickerIndex(slot.endTime)"
@change="(e: any) => updateSlotTime(slot, 'endTime', pickerIndexToTime(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="multiSelector"
:range="timePickerRange"
:value="timeToPickerIndex(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'
import {
SCHEDULE_TIME_PICKER_RANGE,
timeToPickerIndex,
pickerIndexToTime,
addOneHourCapped,
} from '../../utils/schedule-time'
const timePickerRange = SCHEDULE_TIME_PICKER_RANGE
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:30',
endTime: '10:30',
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 ──────────────────────────────────────────────
function onAddStartTimeChange(e: any) {
const start = pickerIndexToTime(e.detail.value as number[])
addForm.value.startTime = start
addForm.value.endTime = addOneHourCapped(start)
}
function openAddModal() {
addForm.value = { startTime: '09:30', endTime: '10:30', 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 (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
@@ -27,7 +28,12 @@
</view>
<view class="form-row">
<text class="form-label">开始时间</text>
<picker mode="time" :value="addForm.startTime" @change="(e: any) => addForm.startTime = e.detail.value">
<picker
mode="multiSelector"
:range="timePickerRange"
:value="timeToPickerIndex(addForm.startTime)"
@change="(e: any) => addForm.startTime = pickerIndexToTime(e.detail.value)"
>
<view class="picker-display">
<text class="picker-text">{{ addForm.startTime || '请选择' }}</text>
<text class="picker-arrow"></text>
@@ -36,7 +42,12 @@
</view>
<view class="form-row">
<text class="form-label">结束时间</text>
<picker mode="time" :value="addForm.endTime" @change="(e: any) => addForm.endTime = e.detail.value">
<picker
mode="multiSelector"
:range="timePickerRange"
:value="timeToPickerIndex(addForm.endTime)"
@change="(e: any) => addForm.endTime = pickerIndexToTime(e.detail.value)"
>
<view class="picker-display">
<text class="picker-text">{{ addForm.endTime || '请选择' }}</text>
<text class="picker-arrow"></text>
@@ -127,7 +138,7 @@
</picker>
</view>
</view>
<text class="gen-hint">根据排课模板自动生成所选日期范围内的时段</text>
<text class="gen-hint">按默认时间表8:00-9:00之后从 9:30 起每小时一节至 21:30自动生成所选日期范围内的时段</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 +149,23 @@
</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'
import {
SCHEDULE_TIME_PICKER_RANGE,
timeToPickerIndex,
pickerIndexToTime,
} from '../../utils/schedule-time'
const timePickerRange = SCHEDULE_TIME_PICKER_RANGE
const adminStore = useAdminStore()
const navBarHeight = ref('64px')
const tabs = ['新增时段', '关闭时段', '批量生成']
const activeTab = ref(0)
const submitting = ref(false)
@@ -153,8 +174,8 @@ const slotsLoading = ref(false)
// ── Add slot form ────────────────────────────────────────────────
const addForm = ref({
date: formatDate(new Date()),
startTime: '09:00',
endTime: '10:00',
startTime: '09:30',
endTime: '10:30',
capacityStr: '10',
})
@@ -164,6 +185,10 @@ async function submitAddSlot() {
uni.showToast({ title: '请填写完整信息', icon: 'none' })
return
}
if (addForm.value.startTime >= addForm.value.endTime) {
uni.showToast({ title: '结束时间须晚于开始时间', icon: 'none' })
return
}
const capacity = parseInt(addForm.value.capacityStr, 10)
submitting.value = true
try {
@@ -242,6 +267,10 @@ async function submitGenerate() {
submitting.value = false
}
}
onMounted(() => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
})
</script>
<style lang="scss" scoped>
@@ -278,7 +307,7 @@ async function submitGenerate() {
}
.tab--active {
border-bottom: 4rpx solid #c9a87c;
border-bottom: 4rpx solid $primary-dark;
}
/* ── Panel ───────────────────────────────── */
@@ -335,11 +364,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 +447,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,43 +1,64 @@
<template>
<view class="booking-page">
<!-- Sticky header area -->
<view class="sticky-header">
<!-- Date selector -->
<DateSelector v-model="selectedDate" @select="onDateSelect" />
<view class="booking-page" :style="{ height: pageHeight }">
<!-- 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">
<view class="calendar-heading">
<text class="calendar-month">{{ selectedMonthLabel }}</text>
<text class="calendar-hint">选择上课日期</text>
</view>
<DateSelector v-model="selectedDate" variant="soft" @select="onDateSelect" />
<TimePeriodFilter v-model="selectedPeriod" variant="soft" @change="onPeriodChange" />
</view>
<!-- Slot list -->
<scroll-view
class="slot-scroll"
scroll-y
:style="{ height: scrollHeight }"
refresher-enabled
:refresher-triggered="refreshing"
@refresherrefresh="onRefresh"
>
<!-- 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" />
<text class="empty-text">当日暂无可约时段</text>
<text class="empty-sub">请选择其他日期或时段</text>
<text class="empty-text">这个时段还没有课程</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-title">{{ selectedDayLabel }}</text>
<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 +69,7 @@
<!-- Confirm popup -->
<BookingConfirmPopup
:visible="showConfirmPopup"
:slot="pendingSlot"
:time-slot="pendingSlot"
:memberships="userStore.activeMemberships as MembershipWithCardType[]"
@confirm="onConfirmBooking"
@cancel="showConfirmPopup = false"
@@ -57,12 +78,15 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { onResize, 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 +105,49 @@ const showConfirmPopup = ref(false)
const pendingSlot = ref<TimeSlotWithBookingStatus | null>(null)
const refreshing = ref(false)
// ─── 微信分享 ───────────────────────────────────────────────
onShareAppMessage(() => {
return {
title: '预约普拉提课程,开启健康新生活',
path: '/pages/booking/index',
imageUrl: '',
}
})
onShareTimeline(() => {
return {
title: '预约普拉提课程,开启健康新生活',
query: '',
}
})
// ─── 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`
const statusBarHeight = ref('20px')
const pageHeight = ref('100vh')
function updateLayout() {
statusBarHeight.value = `${getSystemLayout().statusBarHeight}px`
// The mini-program window already excludes its native tab bar. Flex layout
// gives the remaining height to the list as the filters change size.
pageHeight.value = `${uni.getWindowInfo().windowHeight}px`
}
updateLayout()
onResize(updateLayout)
const selectedMonthLabel = computed(() => {
const [year, month] = selectedDate.value.split('-')
return `${year}${Number(month)}`
})
const selectedDayLabel = computed(() => {
const [, month, day] = selectedDate.value.split('-')
return `${Number(month)}${Number(day)} 日的课程`
})
// ─── 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 +174,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 +213,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 +235,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' })
}
},
})
@@ -224,87 +305,33 @@ onMounted(async () => {
</script>
<style lang="scss" scoped>
.booking-page {
min-height: 100vh;
background: #f5f3f0;
display: flex;
flex-direction: column;
}
/* ── Sticky header ─────────────────────────────────── */
.sticky-header {
position: sticky;
top: 0;
z-index: 100;
background: #fff;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
}
/* ── Scroll container ──────────────────────────────── */
.slot-scroll {
flex: 1;
width: 100%;
}
/* ── Slot list ─────────────────────────────────────── */
.slot-list {
display: flex;
flex-direction: column;
gap: 20rpx;
padding: 28rpx 24rpx 0;
}
/* ── Loading skeleton ──────────────────────────────── */
.loading-wrap {
display: flex;
flex-direction: column;
gap: 20rpx;
padding: 28rpx 24rpx;
}
.skeleton-card {
height: 140rpx;
border-radius: 20rpx;
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; }
}
/* ── Empty state ───────────────────────────────────── */
.empty-wrap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 40rpx;
gap: 16rpx;
}
.empty-img {
width: 200rpx;
height: 200rpx;
opacity: 0.5;
margin-bottom: 8rpx;
}
.empty-text {
font-size: 30rpx;
color: #666;
font-weight: 600;
}
.empty-sub {
font-size: 26rpx;
color: #bbb;
}
/* ── Bottom spacer ─────────────────────────────────── */
.scroll-bottom-spacer {
height: 48rpx;
.booking-page { height: 100vh; background: #fbf9f6; display: flex; flex-direction: column; overflow: hidden; }
.status-bar { flex-shrink: 0; }
.page-header { flex-shrink: 0; height: 88rpx; display: flex; align-items: center; justify-content: center; }
.page-title { font-size: 34rpx; font-weight: 500; color: #514943; }
.filter-header { flex-shrink: 0; border-bottom: 1rpx solid #eee8e3; }
.calendar-heading { display: flex; align-items: baseline; justify-content: space-between; padding: 24rpx 32rpx 16rpx; gap: 16rpx; }
.calendar-month { font-size: 28rpx; font-weight: 500; color: #514943; }
.calendar-hint { font-size: 22rpx; color: #8b817b; }
.slot-scroll { flex: 1; height: 0; min-height: 0; width: 100%; box-sizing: border-box; }
.slot-list { display: flex; flex-direction: column; padding-top: 28rpx; }
.date-summary { display: flex; justify-content: space-between; align-items: baseline; gap: 16rpx; padding: 0 32rpx 20rpx; }
.date-summary-title { font-size: 25rpx; color: #6f655e; }
.date-summary-text { font-size: 22rpx; color: #8b817b; }
.loading-wrap { display: flex; flex-direction: column; gap: 20rpx; padding: 28rpx 32rpx; }
.skeleton-card { height: 200rpx; box-sizing: border-box; border-radius: 28rpx; background: #fff; display: flex; align-items: center; padding: 28rpx; gap: 20rpx; }
.skeleton-time, .skeleton-title, .skeleton-sub, .skeleton-btn {
border-radius: 10rpx;
background: linear-gradient(90deg, #f0eae5 25%, #faf7f3 50%, #f0eae5 75%);
background-size: 400% 100%; animation: shimmer 1.4s infinite;
}
.skeleton-time { width: 90rpx; height: 70rpx; flex-shrink: 0; }
.skeleton-body { flex: 1; display: flex; flex-direction: column; gap: 12rpx; }
.skeleton-title { width: 80%; height: 28rpx; }
.skeleton-sub { width: 60%; height: 20rpx; }
.skeleton-btn { width: 110rpx; height: 60rpx; border-radius: 999rpx; flex-shrink: 0; }
.empty-wrap { margin: 32rpx; padding: 64rpx 28rpx; border-radius: 28rpx; background: #f3efea; display: flex; flex-direction: column; align-items: center; gap: 14rpx; }
.empty-text { font-size: 28rpx; color: #6f655e; font-weight: 400; }
.empty-sub { font-size: 23rpx; color: #8b817b; text-align: center; line-height: 1.6; }
.scroll-bottom-spacer { height: 28rpx; }
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,848 @@
<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 v-if="invite.eligible" class="hero-discount-text">好友 95 </text>
<text class="hero-price">{{ formatPrice(invite.price(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 - invite.price(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(invite.price(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 { useInviteStore } from "../../stores/invite"
const invite = useInviteStore()
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(invite.price(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
}
try { await invite.refresh() } catch (err) {
uni.showToast({ title: getErrorMessage(err, '暂时无法核对优惠,请重试'), icon: 'none' })
return
}
// Confirm purchase
uni.showModal({
title: '确认抢购',
content: `确认以 ¥${formatPrice(invite.price(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,39 @@
<template>
<view class="home-page">
<!-- Pull-to-refresh wrapper -->
<scroll-view
class="page-scroll"
scroll-y
:refresher-enabled="true"
:refresher-triggered="refreshing"
@refresherrefresh="handleRefresh"
@refresherrestore="refreshing = false"
>
<!-- Brand Banner (hero with bg image + centered logo) -->
<view class="home-page" :style="{ height: pageHeight }">
<view class="banner-fixed">
<BrandBanner :studio-info="studioStore.studioInfo" />
<!-- Studio Info (photo strip + address/phone) -->
<StudioInfo :studio-info="studioStore.studioInfo" />
<!-- Divider -->
<view class="section-divider" />
<!-- Quick Entry (login / trial / book / renew) -->
<QuickEntry @scroll-to-card-shop="scrollToCardShop" />
<!-- Upcoming Bookings -->
<UpcomingBooking />
<!-- Card Shop (vertical list) -->
<view :id="cardShopAnchorId">
<CardShop ref="cardShopRef" />
</view>
<scroll-view class="page-scroll" scroll-y :scroll-into-view="scrollTarget"
scroll-with-animation refresher-enabled :refresher-triggered="refreshing"
@refresherrefresh="handleRefresh" @refresherrestore="refreshing = false">
<view class="banner-spacer" />
<view class="floating-card">
<view class="card-handle"><view class="card-handle-bar" /></view>
<QuickEntry @scroll-to-card-shop="scrollToCardShop" />
<UpcomingBooking />
<StudioInfo :studio-info="studioStore.studioInfo" />
<FlashSaleSection ref="flashSaleRef" />
<view :id="cardShopAnchorId">
<CardShop ref="cardShopRef" />
</view>
<AboutSection />
<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, onResize, 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 +43,51 @@ 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 scrollTarget = ref('')
const pageHeight = ref(`${uni.getWindowInfo().windowHeight}px`)
onResize(() => { pageHeight.value = `${uni.getWindowInfo().windowHeight}px` })
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 +95,7 @@ async function refreshData() {
if (userStore.loggedIn) {
tasks.push(
userStore.fetchProfile(),
userStore.fetchMemberships(),
bookingStore.fetchUpcomingBookings(),
)
@@ -74,8 +103,11 @@ async function refreshData() {
await Promise.allSettled(tasks)
// Also refresh card shop
cardShopRef.value?.fetchCardTypes()
// Also refresh card shop and flash sales
await Promise.allSettled([
cardShopRef.value?.fetchCardTypes(),
flashSaleRef.value?.fetchFlashSales(),
])
}
async function handleRefresh() {
@@ -87,30 +119,30 @@ async function handleRefresh() {
}
}
function scrollToCardShop() {
uni.pageScrollTo({
selector: `#${cardShopAnchorId}`,
duration: 300,
})
async function scrollToCardShop() {
scrollTarget.value = ''
await nextTick()
scrollTarget.value = cardShopAnchorId
}
</script>
<style lang="scss" scoped>
.home-page {
.home-page { position: relative; height: 100vh; background: #fbf9f6; }
.banner-fixed { position: fixed; top: 0; left: 0; width: 100%; z-index: 0; }
.page-scroll { position: relative; z-index: 1; height: 100%; }
.banner-spacer { height: 420rpx; }
.floating-card {
position: relative;
min-height: 100vh;
background: #f5f5f5;
}
.page-scroll {
height: 100vh;
}
.section-divider {
height: 16rpx;
background: #f5f5f5;
}
.bottom-padding {
height: 120rpx;
padding-top: 12rpx;
border-radius: 32rpx 32rpx 0 0;
border-top: 1rpx solid rgba(255, 255, 255, 0.7);
background: linear-gradient(180deg, rgba(251, 249, 246, 0.86), #fbf9f6 260rpx);
-webkit-backdrop-filter: blur(16px);
backdrop-filter: blur(16px);
box-shadow: 0 -4rpx 20rpx rgba(66, 54, 45, 0.06);
}
.card-handle { display: flex; justify-content: center; padding: 16rpx 0 8rpx; }
.card-handle-bar { width: 64rpx; height: 8rpx; border-radius: 4rpx; background: rgba(112, 94, 79, 0.18); }
.bottom-padding { height: 48rpx; }
</style>

View File

@@ -1,22 +1,20 @@
<template>
<view class="bookings-page">
<!-- Tab bar -->
<view class="tab-bar">
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="我的预约" show-back />
<view class="tabs">
<view
v-for="tab in tabs"
:key="tab.key"
class="tab-item"
:class="{ active: activeTab === tab.key }"
class="tabs-item"
:class="{ 'tabs-item--on': activeTab === tab.key }"
@tap="selectTab(tab.key)"
>
<text class="tab-label">{{ tab.label }}</text>
<view v-if="tab.key === 'upcoming' && upcomingCount > 0" class="tab-badge">
<text class="tab-badge-text">{{ upcomingCount }}</text>
</view>
<text class="tabs-label">{{ tab.label }}</text>
<text v-if="tab.key === 'upcoming' && upcomingCount > 0" class="tabs-count">{{ upcomingCount }}</text>
</view>
</view>
<!-- Upcoming tab content -->
<scroll-view
v-show="activeTab === 'upcoming'"
class="scroll"
@@ -25,57 +23,55 @@
:refresher-triggered="refreshingUpcoming"
@refresherrefresh="onRefreshUpcoming"
>
<!-- 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-row" />
</view>
<!-- Empty -->
<view v-else-if="upcomingBookings.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="goBooking">
<text class="empty-btn-text">去预约</text>
<view v-else-if="upcomingGroups.length === 0" class="state-wrap">
<text class="state-mark"></text>
<text class="state-title">暂无即将上课的预约</text>
<text class="state-copy">去课表选一个时段即可</text>
<view class="state-btn" @tap="goBooking">
<text class="state-btn-text">去预约</text>
</view>
</view>
<!-- Booking list -->
<view v-else class="list">
<view
v-for="booking in upcomingBookings"
:key="booking.id"
class="booking-card"
>
<view class="booking-stripe stripe--confirmed" />
<view class="booking-content">
<view class="booking-main">
<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) }}
<view v-for="group in upcomingGroups" :key="group.key" class="day-group">
<view class="day-head">
<text class="day-label">{{ group.label }}</text>
<text class="day-count">{{ group.items.length }} </text>
</view>
<view
v-for="booking in group.items"
:key="booking.id"
class="row"
@tap="goDetail(booking)"
>
<view class="row-rail" :class="bookingStatusStripeClass(booking.status)" />
<view class="row-body">
<view class="row-top">
<view class="row-time-wrap">
<text class="row-time">{{ startTime(booking) }}</text>
<text class="row-end"> {{ endTime(booking) }}</text>
</view>
<text class="row-stamp" :class="stampClass(booking.status)">
{{ bookingStatusLabel(booking.status) }}
</text>
</view>
<view class="status-badge badge--confirmed">
<text class="status-text">已预约</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)">
<text class="cancel-text">取消预约</text>
<view class="row-bottom">
<text class="row-meta">{{ cardName(booking) }}</text>
<text class="row-cancel" @tap.stop="handleCancel(booking)">取消</text>
</view>
</view>
</view>
</view>
</view>
<view class="scroll-bottom-spacer" />
<view class="scroll-bottom" />
</scroll-view>
<!-- History tab content -->
<scroll-view
v-show="activeTab === 'history'"
class="scroll"
@@ -84,61 +80,95 @@
:refresher-triggered="refreshingHistory"
@refresherrefresh="onRefreshHistory"
>
<!-- Loading -->
<view v-if="supplements.length || supplementsError || supplementsLoading" class="supplement">
<text class="section-label">历史补录</text>
<text v-if="supplementsLoading" class="supplement-message">正在加载补录记录</text>
<button v-else-if="supplementsError" class="supplement-retry" @tap="fetchSupplements">
补录记录加载失败点击重试
</button>
<LessonSupplementList v-else :records="supplements" />
</view>
<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-row" />
</view>
<!-- Empty -->
<view v-else-if="historyBookings.length === 0" class="empty-wrap">
<text class="empty-icon">📋</text>
<text class="empty-title">暂无历史记录</text>
<text class="empty-sub">已完成或取消的课程将显示在这里</text>
<view
v-else-if="historyGroups.length === 0 && !supplements.length && !supplementsLoading && !supplementsError"
class="state-wrap"
>
<text class="state-mark"></text>
<text class="state-title">暂无历史记录</text>
<text class="state-copy">上完或取消的课程会显示在这里</text>
</view>
<!-- Booking list -->
<view v-else class="list">
<view
v-for="booking in historyBookings"
:key="booking.id"
class="booking-card"
>
<view class="booking-stripe" :class="stripeClass(booking.status)" />
<view class="booking-content">
<view class="booking-main">
<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) }}
<view v-else-if="historyGroups.length > 0" class="list">
<view v-for="group in historyGroups" :key="group.key" class="day-group">
<view class="day-head">
<text class="day-label">{{ group.label }}</text>
<text class="day-count">{{ group.items.length }} </text>
</view>
<view
v-for="booking in group.items"
:key="booking.id"
class="row"
:class="{ 'row--muted': isMutedStatus(booking.status) }"
@tap="goDetail(booking)"
>
<view class="row-rail" :class="bookingStatusStripeClass(booking.status)" />
<view class="row-body">
<view class="row-top">
<view class="row-time-wrap">
<text class="row-time">{{ startTime(booking) }}</text>
<text class="row-end"> {{ endTime(booking) }}</text>
</view>
<text class="row-stamp" :class="stampClass(booking.status)">
{{ bookingStatusLabel(booking.status) }}
</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>
<text class="row-meta">{{ historyDayLabel(booking.timeSlot.date) }} · {{ cardName(booking) }}</text>
</view>
</view>
</view>
</view>
<view class="scroll-bottom-spacer" />
<view class="scroll-bottom" />
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import type { BookingWithDetails } from '@mp-pilates/shared'
import { onShow } from '@dcloudio/uni-app'
import type { BookingWithDetails, LessonSupplementRecord } from '@mp-pilates/shared'
import { BookingStatus } from '@mp-pilates/shared'
import { useBookingStore } from '../../stores/booking'
import { formatDate, getWeekdayLabel } from '../../utils/format'
import { formatDate } from '../../utils/format'
import { getSystemLayout } from '../../utils/system'
import { get } from '../../utils/request'
import {
formatDateDisplay,
bookingStatusLabel,
bookingStatusStripeClass,
} from '../../utils/booking-helpers'
import CustomNavBar from '../../components/CustomNavBar.vue'
import LessonSupplementList from '../../components/LessonSupplementList.vue'
type TabKey = 'upcoming' | 'history'
interface BookingGroup {
key: string
label: string
items: BookingWithDetails[]
}
const bookingStore = useBookingStore()
const supplements = ref<LessonSupplementRecord[]>([])
const supplementsLoading = ref(false)
const supplementsError = ref(false)
// ─── Tab state ────────────────────────────────────────────
type TabKey = 'upcoming' | 'history'
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
const tabs = [
{ key: 'upcoming' as TabKey, label: '即将上课' },
@@ -148,83 +178,130 @@ const tabs = [
const activeTab = ref<TabKey>('upcoming')
const refreshingUpcoming = ref(false)
const refreshingHistory = ref(false)
const hasLoadedOnce = ref(false)
async function fetchSupplements() {
supplementsLoading.value = true
supplementsError.value = false
try {
supplements.value = await get<LessonSupplementRecord[]>('/user/lesson-supplements')
} catch {
supplementsError.value = true
} finally {
supplementsLoading.value = false
}
}
function safeBookings(): readonly BookingWithDetails[] {
const raw = bookingStore.myBookings
return Array.isArray(raw) ? raw : []
}
function toDateStr(date: string): string {
return date.slice(0, 10)
}
function isUpcomingBooking(booking: BookingWithDetails, todayStr: string): boolean {
const upcomingStatus =
booking.status === BookingStatus.PENDING_CONFIRMATION ||
booking.status === BookingStatus.CONFIRMED
return upcomingStatus && toDateStr(booking.timeSlot.date) >= todayStr
}
// ─── Filtered bookings ────────────────────────────────────
const today = computed(() => formatDate(new Date()))
const upcomingBookings = computed<BookingWithDetails[]>(() => {
const all = bookingStore.myBookings as BookingWithDetails[]
return all
.filter(
(b) => b.status === BookingStatus.CONFIRMED && b.timeSlot.date >= today.value,
)
return safeBookings()
.filter((booking) => isUpcomingBooking(booking, 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
.filter(
(b) =>
b.status !== BookingStatus.CONFIRMED ||
b.timeSlot.date < today.value,
)
return safeBookings()
.filter((booking) => !isUpcomingBooking(booking, 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)
})
})
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]: '未出席',
const upcomingGroups = computed(() =>
groupBookings(
upcomingBookings.value,
(booking) => toDateStr(booking.timeSlot.date),
(key) => formatDateDisplay(key),
),
)
const historyGroups = computed(() =>
groupBookings(
historyBookings.value,
(booking) => toDateStr(booking.timeSlot.date).slice(0, 7),
(key) => {
const [year, month] = key.split('-')
return `${year}${Number(month)}`
},
),
)
function groupBookings(
bookings: BookingWithDetails[],
getKey: (booking: BookingWithDetails) => string,
getLabel: (key: string) => string,
): BookingGroup[] {
const map = new Map<string, BookingWithDetails[]>()
for (const booking of bookings) {
const key = getKey(booking)
const list = map.get(key)
if (list) list.push(booking)
else map.set(key, [booking])
}
return map[status] ?? status
return Array.from(map, ([key, items]) => ({
key,
label: getLabel(key),
items,
}))
}
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] ?? ''
function startTime(booking: BookingWithDetails): string {
return booking.timeSlot.startTime.slice(0, 5)
}
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] ?? ''
function endTime(booking: BookingWithDetails): string {
return booking.timeSlot.endTime.slice(0, 5)
}
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}`
function cardName(booking: BookingWithDetails): string {
return booking.membership?.cardType?.name || '会员卡'
}
function historyDayLabel(dateStr: string): string {
const [year, month, day] = toDateStr(dateStr).split('-').map(Number)
const weekday = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][new Date(year, month - 1, day).getDay()]
return `${day}${weekday}`
}
function stampClass(status: string): string {
if (status === BookingStatus.PENDING_CONFIRMATION) return 'stamp--pending'
if (status === BookingStatus.CANCELLED) return 'stamp--cancelled'
if (status === BookingStatus.NO_SHOW) return 'stamp--noshow'
if (status === BookingStatus.COMPLETED) return 'stamp--completed'
return 'stamp--confirmed'
}
function isMutedStatus(status: string): boolean {
return status === BookingStatus.CANCELLED || status === BookingStatus.NO_SHOW
}
// ─── Actions ──────────────────────────────────────────────
function selectTab(key: TabKey) {
activeTab.value = key
}
@@ -237,7 +314,7 @@ async function onRefreshUpcoming() {
async function onRefreshHistory() {
refreshingHistory.value = true
await bookingStore.fetchMyBookings()
await Promise.all([bookingStore.fetchMyBookings(), fetchSupplements()])
refreshingHistory.value = false
}
@@ -245,9 +322,13 @@ 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)
const timeLabel = startTime(booking)
uni.showModal({
title: '取消预约',
@@ -272,262 +353,322 @@ async function handleCancel(booking: BookingWithDetails) {
})
}
// ─── Lifecycle ────────────────────────────────────────────
onMounted(() => bookingStore.fetchMyBookings())
onMounted(() => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
fetchSupplements()
bookingStore.fetchMyBookings().then(() => {
hasLoadedOnce.value = true
})
})
onShow(() => {
if (!hasLoadedOnce.value) return
fetchSupplements()
bookingStore.fetchMyBookings(undefined, { silent: true })
})
</script>
<style lang="scss" scoped>
.bookings-page {
min-height: 100vh;
background: #f5f3f0;
.page {
height: 100vh;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
background: #fbf9f6;
color: #514943;
}
/* ── Tab bar ─────────────────────────────────────────── */
.tab-bar {
.tabs {
display: flex;
flex-direction: row;
background: #fff;
border-bottom: 1rpx solid #f0ece8;
flex-shrink: 0;
border-bottom: 1rpx solid #eee8e3;
}
.tab-item {
.tabs-item {
flex: 1;
height: 88rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 8rpx;
padding: 28rpx 0;
position: relative;
&.active {
.tab-label {
color: #c9a87c;
font-weight: 600;
&:active {
opacity: 0.7;
}
&--on {
.tabs-label {
color: #3d3833;
}
&::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
bottom: 0;
width: 48rpx;
height: 4rpx;
background: #c9a87c;
border-radius: 2rpx;
background: #6b8276;
transform: translateX(-50%);
}
}
}
.tab-label {
.tabs-label {
font-size: 28rpx;
color: #999;
font-weight: 400;
color: #8b817b;
}
.tab-badge {
min-width: 32rpx;
height: 32rpx;
border-radius: 16rpx;
background: #ef4444;
display: flex;
align-items: center;
justify-content: center;
padding: 0 8rpx;
.tabs-count {
font-size: 24rpx;
color: #6b8276;
font-variant-numeric: tabular-nums;
}
.tab-badge-text {
font-size: 20rpx;
color: #fff;
font-weight: 600;
}
/* ── Scroll ──────────────────────────────────────────── */
.scroll {
flex: 1;
height: calc(100vh - 88rpx);
height: 0;
min-height: 0;
width: 100%;
}
/* ── Loading ─────────────────────────────────────────── */
.loading-wrap {
padding: 24rpx;
padding: 28rpx 32rpx;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.skeleton-card {
height: 160rpx;
border-radius: 16rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
.skeleton-row {
height: 148rpx;
border-radius: 24rpx;
background: linear-gradient(90deg, #eee8e2 25%, #f9f6f2 50%, #eee8e2 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
animation: shimmer 1.5s infinite;
}
.state-wrap {
min-height: 520rpx;
padding: 80rpx 48rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.state-mark {
font-size: 48rpx;
color: #c9c0b8;
margin-bottom: 16rpx;
}
.state-title {
font-size: 32rpx;
color: #514943;
margin-bottom: 10rpx;
}
.state-copy {
font-size: 24rpx;
color: #8b817b;
text-align: center;
line-height: 1.6;
}
.state-btn {
margin-top: 32rpx;
min-width: 200rpx;
height: 80rpx;
padding: 0 40rpx;
border-radius: 999rpx;
background: #6b8276;
display: flex;
align-items: center;
justify-content: center;
&:active {
opacity: 0.88;
}
}
.state-btn-text {
font-size: 28rpx;
color: #fff;
}
.list {
padding: 24rpx 0 0;
}
.day-group {
margin: 0 32rpx 28rpx;
}
.day-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16rpx;
padding: 4rpx 4rpx 16rpx;
}
.day-label {
font-size: 26rpx;
color: #6f655e;
}
.day-count {
font-size: 22rpx;
color: #8b817b;
}
.row {
display: flex;
margin-bottom: 16rpx;
border: 1rpx solid #eee8e3;
border-radius: 24rpx;
background: #fff;
overflow: hidden;
&:last-child {
margin-bottom: 0;
}
&--muted {
background: #f7f5f2;
.row-time {
color: #8b817b;
}
}
}
.row-rail {
width: 6rpx;
flex-shrink: 0;
background: #6b8276;
&.stripe--pending { background: #c4a064; }
&.stripe--confirmed { background: #6b8276; }
&.stripe--completed { background: #6b8276; }
&.stripe--cancelled { background: #d4ccc4; }
&.stripe--noshow { background: #c47a74; }
}
.row-body {
flex: 1;
min-width: 0;
padding: 24rpx 28rpx;
}
.row-top {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16rpx;
}
.row-time-wrap {
display: flex;
align-items: baseline;
gap: 10rpx;
min-width: 0;
}
.row-time {
font-size: 44rpx;
font-weight: 500;
line-height: 1;
color: #3d3833;
font-variant-numeric: tabular-nums;
font-family: "Songti SC", "STSong", "Noto Serif SC", serif;
}
.row-end {
font-size: 26rpx;
color: #8b817b;
font-variant-numeric: tabular-nums;
}
.row-stamp {
flex-shrink: 0;
font-size: 22rpx;
letter-spacing: 1rpx;
color: #5a6f65;
&--pending { color: #9b6e22; }
&--confirmed,
&--completed { color: #5a6f65; }
&--cancelled { color: #8b817b; }
&--noshow { color: #b45a54; }
}
.row-bottom {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
margin-top: 14rpx;
}
.row-meta {
display: block;
margin-top: 12rpx;
font-size: 24rpx;
color: #8b817b;
line-height: 1.4;
.row-bottom & {
margin-top: 0;
}
}
.row-cancel {
flex-shrink: 0;
padding: 8rpx 0 0 20rpx;
font-size: 24rpx;
color: #8b817b;
}
.supplement {
padding: 24rpx 32rpx 8rpx;
}
.section-label {
display: block;
margin-bottom: 16rpx;
font-size: 22rpx;
color: #8b817b;
letter-spacing: 2rpx;
}
.supplement-message,
.supplement-retry {
font-size: 24rpx;
color: #8b817b;
}
.supplement-retry {
width: 100%;
margin: 0;
padding: 24rpx;
line-height: 1.6;
background: #f3eee8;
border-radius: 20rpx;
&::after {
border: none;
}
}
.scroll-bottom {
height: 40rpx;
}
@keyframes shimmer {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}
/* ── Empty ───────────────────────────────────────────── */
.empty-wrap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 40rpx;
gap: 20rpx;
}
.empty-icon {
font-size: 80rpx;
}
.empty-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.empty-sub {
font-size: 26rpx;
color: #999;
}
.empty-btn {
margin-top: 12rpx;
padding: 20rpx 56rpx;
border-radius: 44rpx;
background: #c9a87c;
}
.empty-btn-text {
font-size: 30rpx;
color: #fff;
font-weight: 600;
}
/* ── List ────────────────────────────────────────────── */
.list {
padding: 24rpx 24rpx 0;
display: flex;
flex-direction: column;
gap: 16rpx;
}
/* ── Booking card ────────────────────────────────────── */
.booking-card {
background: #fff;
border-radius: 16rpx;
overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
display: flex;
flex-direction: row;
}
/* Colored left stripe */
.booking-stripe {
width: 8rpx;
flex-shrink: 0;
&.stripe--confirmed { background: #c9a87c; }
&.stripe--completed { background: #4caf50; }
&.stripe--cancelled { background: #e0e0e0; }
&.stripe--noshow { background: #ef4444; }
}
.booking-content {
flex: 1;
padding: 24rpx 24rpx 24rpx 20rpx;
display: flex;
flex-direction: column;
gap: 12rpx;
}
.booking-main {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
}
.booking-datetime {
display: flex;
flex-direction: column;
gap: 6rpx;
}
.booking-date {
font-size: 28rpx;
font-weight: 600;
color: #1a1a1a;
}
.booking-time {
font-size: 24rpx;
color: #888;
}
/* Status badge */
.status-badge {
padding: 8rpx 18rpx;
border-radius: 20rpx;
flex-shrink: 0;
&.badge--confirmed { background: #fff8ee; }
&.badge--completed { background: #f0faf3; }
&.badge--cancelled { background: #f5f5f5; }
&.badge--noshow { background: #fef0f0; }
}
.status-text {
font-size: 22rpx;
font-weight: 600;
.badge--confirmed & { color: #c9a87c; }
.badge--completed & { color: #4caf50; }
.badge--cancelled & { color: #bbb; }
.badge--noshow & { color: #ef4444; }
}
/* Meta info */
.booking-meta {
.meta-text {
font-size: 24rpx;
color: #999;
}
}
/* Cancel row */
.cancel-row {
display: flex;
justify-content: flex-end;
margin-top: 4rpx;
}
.cancel-btn {
padding: 10rpx 24rpx;
border-radius: 24rpx;
border: 1rpx solid #ef444430;
background: #fef0f0;
&:active {
opacity: 0.75;
}
}
.cancel-text {
font-size: 24rpx;
color: #ef4444;
font-weight: 500;
}
/* ── Spacer ──────────────────────────────────────────── */
.scroll-bottom-spacer {
height: 48rpx;
}
</style>

View File

@@ -1,24 +1,27 @@
<template>
<view class="profile-page">
<view class="profile-page" :style="{ paddingTop: navBarHeight + 'px' }">
<view class="profile-page__nav" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="profile-page__nav-title">我的</view>
</view>
<!-- 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" @login="handleLogin" @edit="goToInfo" />
<InviteCard />
<!-- 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"
/>
>
<PracticeActivityCard v-if="loggedIn" :key="userStore.token" :refresh-key="activityRefreshKey" />
</ProfileMenu>
<!-- Logout button: only when logged in -->
<view v-if="loggedIn" class="profile-page__logout-wrap">
@@ -28,24 +31,68 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import InviteCard from '../../components/InviteCard.vue'
import { useInviteStore } from '../../stores/invite'
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 PracticeActivityCard from '../../components/PracticeActivityCard.vue'
import UserCard from '../../components/UserCard.vue'
import ProfileMenu from '../../components/ProfileMenu.vue'
const invite = useInviteStore()
const userStore = useUserStore()
const bookingStore = useBookingStore()
const { loggedIn, hasProfile, user, stats, memberships, isAdmin } = storeToRefs(userStore)
const { upcomingBookings } = storeToRefs(bookingStore)
const activityRefreshKey = ref(0)
const loginLoading = ref(false)
const navBarHeight = ref(getSystemLayout().navBarHeight)
const statusBarHeight = getSystemLayout().statusBarHeight
const activeMembershipCount = computed(
() => user.value?.activeMembershipCount ?? userStore.activeMemberships.length,
)
const upcomingBookingCount = computed(
() => (loggedIn.value ? upcomingBookings.value.length : 0),
)
// ─── 微信分享 ───────────────────────────────────────────────
onShareAppMessage(() => {
return {
title: invite.code ? '送你 95 折购卡礼,一起练普拉提' : '一起练普拉提',
path: invite.code ? `/pages/card/detail?showAll=1&inviteCode=${invite.code}` : '/pages/home/index',
imageUrl: '',
}
})
onShareTimeline(() => {
return {
title: '我的普拉提会所,记录每一次进步',
query: '',
}
})
onMounted(() => {
navBarHeight.value = getSystemLayout().navBarHeight
})
onShow(async () => {
activityRefreshKey.value += 1
if (loggedIn.value) {
await Promise.all([
invite.refresh().catch(() => {}),
invite.refreshActivity().catch(() => {}),
userStore.fetchProfile(),
userStore.fetchStats(),
userStore.fetchMemberships(),
bookingStore.fetchUpcomingBookings(),
])
}
})
@@ -54,25 +101,31 @@ 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([
invite.refreshActivity().catch(() => {}),
userStore.fetchStats(),
bookingStore.fetchUpcomingBookings(),
])
}
} catch (err: unknown) {
uni.showToast({ title: getErrorMessage(err, '登录失败,请重试'), icon: 'none' })
} finally {
loginLoading.value = false
}
}
function goToInfo() {
uni.navigateTo({ url: '/pages/profile/info' })
}
function handleLogout() {
uni.showModal({
title: '退出登录',
content: '确定要退出登录吗?',
confirmText: '退出',
confirmColor: '#ff4d4f',
confirmColor: '#9c7a6e',
success(res) {
if (res.confirm) {
userStore.logout()
@@ -94,39 +147,14 @@ 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;
}
&__logout-btn {
width: 100%;
height: 88rpx;
line-height: 88rpx;
background: $bg-card;
color: $error-color;
font-size: 30rpx;
font-weight: 500;
border: none;
border-radius: $radius-lg;
text-align: center;
&::after {
border: none;
}
}
min-height: 100vh; box-sizing: border-box; background: #fbf9f6; padding-bottom: 36rpx;
&__nav { position: fixed; top: 0; left: 0; right: 0; z-index: 101; background: #fbf9f6; }
&__nav-title { height: 88rpx; display: flex; align-items: center; justify-content: center; font-size: 34rpx; font-weight: 500; color: #514943; }
&__logout-wrap { margin: 28rpx 32rpx 0; }
&__logout-btn { width: 100%; height: 80rpx; line-height: 80rpx; padding: 0; margin: 0; border: none; border-radius: 24rpx; background: #f2ede7; color: #91766a; font-size: 25rpx; font-weight: 400; &::after { border: none; } }
}
</style>

View File

@@ -1,92 +1,82 @@
<template>
<view class="info-page">
<!-- Avatar section -->
<view class="info-page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar :title="isFromLogin ? '完善个人信息' : '个人信息'" :show-back="!isFromLogin" />
<view v-if="isFromLogin" class="welcome-banner">
<text class="welcome-title">欢迎来到工作室</text>
<text class="welcome-desc">设置头像和昵称方便老师认识你</text>
</view>
<view class="avatar-section">
<button class="avatar-btn" open-type="chooseAvatar" @chooseavatar="handleChooseAvatar">
<button class="avatar-btn" open-type="chooseAvatar" :disabled="busy" @chooseavatar="handleChooseAvatar">
<view class="avatar-wrap">
<image
v-if="avatarUrl"
class="avatar"
:src="avatarUrl"
mode="aspectFill"
/>
<view v-else class="avatar-placeholder">
<text class="avatar-placeholder-text">{{ nicknameInitial }}</text>
</view>
<image v-if="displayAvatarUrl" class="avatar" :src="displayAvatarUrl" mode="aspectFill" @error="avatarFailed = true" />
<view v-else class="avatar-placeholder"><text>{{ nicknameInitial }}</text></view>
</view>
<view class="avatar-copy">
<text class="avatar-title">头像</text>
<text class="avatar-action">{{ uploadingAvatar ? '正在更新…' : '点击更换' }}</text>
</view>
<text class="avatar-arrow"></text>
</button>
<text class="avatar-name">{{ form.nickname || '未设置昵称' }}</text>
<text class="avatar-hint">微信头像</text>
<text class="avatar-hint">头像更换后自动保存</text>
</view>
<!-- Form fields -->
<view class="form-card">
<!-- Nickname (editable) -->
<view class="form-row">
<text class="form-label">昵称</text>
<input
class="form-input"
type="nickname"
v-model="form.nickname"
placeholder="点击右侧按钮获取微信昵称"
placeholder-style="color: #ccc"
maxlength="20"
:disabled="saving"
/>
<text class="form-arrow"></text>
</view>
<!-- Phone -->
<view class="form-row form-row--last">
<text class="form-label">手机号</text>
<!-- Phone set: display masked -->
<text v-if="hasPhone" class="form-value">{{ phoneDisplay }}</text>
<!-- Phone not set: bind button -->
<button
v-else
class="bind-phone-btn"
open-type="getPhoneNumber"
@getphonenumber="handleGetPhone"
>
<text class="bind-phone-text">绑定手机号</text>
</button>
<view class="form-section">
<text class="section-title">基本资料</text>
<view class="form-card">
<view class="nickname-field">
<view class="field-heading">
<text class="form-label">昵称</text>
<text class="field-count">{{ form.nickname.length }} / 20</text>
</view>
<input class="form-input" type="nickname" v-model="form.nickname" placeholder="输入昵称,或选择微信昵称"
placeholder-style="color: #a2978e" maxlength="20" :disabled="busy" :cursor-spacing="24" />
</view>
<view v-if="!isFromLogin" class="form-row">
<text class="form-label">手机号</text>
<text v-if="hasPhone" class="form-value">{{ phoneDisplay }}</text>
<button v-else class="bind-phone-btn" open-type="getPhoneNumber" :disabled="busy" :loading="bindingPhone" @getphonenumber="handleGetPhone">绑定手机号</button>
</view>
</view>
</view>
<!-- Read-only info card -->
<view class="info-card">
<view class="info-row">
<text class="info-label">注册时间</text>
<text class="info-value">{{ joinDateDisplay }}</text>
</view>
<view class="info-row info-row--last">
<text class="info-label">会员卡数量</text>
<text class="info-value">{{ activeMembershipCount }} 张有效</text>
</view>
<view v-if="!isFromLogin" class="info-card">
<view class="info-row"><text class="info-label">注册时间</text><text class="info-value">{{ joinDateDisplay }}</text></view>
<view class="info-row"><text class="info-label">有效会员卡</text><text class="info-value">{{ activeMembershipCount }} </text></view>
</view>
<!-- Save button -->
<view class="save-wrap">
<view
class="save-btn"
:class="{ 'save-btn--loading': saving, 'save-btn--disabled': !isDirty || saving }"
@tap="handleSave"
>
<text class="save-btn-text">{{ saving ? '保存中...' : '保存修改' }}</text>
</view>
<button class="save-btn" :disabled="saveDisabled" :loading="saving" @tap="handleSave">
{{ saving ? '保存中' : isFromLogin ? '保存并进入' : '保存修改' }}
</button>
<text v-if="!isFromLogin" class="save-hint">{{ isDirty ? '昵称修改后请点击保存' : '修改昵称后即可保存' }}</text>
<button v-if="isFromLogin" class="skip-btn" :disabled="busy" @tap="handleSkip">稍后再说</button>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, watch } 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(`${getSystemLayout().navBarHeight}px`)
// ─── Form state ───────────────────────────────────────────
const form = ref({
nickname: '',
@@ -94,6 +84,11 @@ const form = ref({
const originalNickname = ref('')
const saving = ref(false)
const uploadingAvatar = ref(false)
const bindingPhone = ref(false)
const initializing = ref(true)
const avatarFailed = ref(false)
const busy = computed(() => saving.value || uploadingAvatar.value || bindingPhone.value || initializing.value)
const saveDisabled = computed(() => busy.value || (!isFromLogin.value && !isDirty.value))
// ─── Computed ─────────────────────────────────────────────
const isDirty = computed(() => form.value.nickname.trim() !== originalNickname.value)
@@ -125,15 +120,19 @@ const activeMembershipCount = computed(
() => userStore.user?.activeMembershipCount ?? userStore.activeMemberships.length,
)
// Use a local fallback when no avatar is set or the remote image fails.
const displayAvatarUrl = computed(() => avatarFailed.value ? '' : avatarUrl.value)
watch(avatarUrl, () => { avatarFailed.value = false })
// ─── Avatar upload ────────────────────────────────────────
async function handleChooseAvatar(e: { detail: { avatarUrl: string } }) {
const { avatarUrl } = e.detail
if (!avatarUrl) return
if (!avatarUrl || busy.value) return
uploadingAvatar.value = true
try {
await userStore.updateProfile({ avatarUrl })
await userStore.fetchProfile()
avatarFailed.value = false
uni.showToast({ title: '头像更新成功', icon: 'success' })
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : '更新失败,请重试'
@@ -147,32 +146,35 @@ async function handleChooseAvatar(e: { detail: { avatarUrl: string } }) {
async function handleGetPhone(e: {
detail: { encryptedData: string; iv: string; errMsg: string }
}) {
if (busy.value) return
if (e.detail.errMsg !== 'getPhoneNumber:ok') {
// User denied or cancelled
return
}
bindingPhone.value = true
uni.showLoading({ title: '绑定中...' })
try {
const updated = await wxBindPhone(e as Parameters<typeof wxBindPhone>[0])
await wxBindPhone(e as Parameters<typeof wxBindPhone>[0])
// Refresh store with updated profile
await userStore.fetchProfile()
uni.hideLoading()
uni.showToast({ title: '手机号绑定成功', icon: 'success' })
// Sync nickname from updated profile
if (updated.nickname) {
form.value = { nickname: updated.nickname }
originalNickname.value = updated.nickname
}
// Keep the local nickname draft while refreshing the phone information.
} catch (err: unknown) {
uni.hideLoading()
const msg = err instanceof Error ? err.message : '绑定失败,请重试'
uni.showToast({ title: msg, icon: 'none' })
} finally {
bindingPhone.value = false
}
}
// ─── Save ─────────────────────────────────────────────────
async function handleSave() {
if (!isDirty.value || saving.value) return
if (busy.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 +191,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,228 +208,77 @@ async function handleSave() {
}
}
// ─── Skip (first-login only) ─────────────────────────────
function handleSkip() {
if (busy.value) return
uni.showModal({
title: '确认跳过?',
content: '完善头像和昵称可以让教练和伙伴更容易认识你',
confirmText: '去完善',
cancelText: '跳过',
success(res) {
if (!res.confirm) {
uni.navigateBack()
}
},
})
}
// ─── Lifecycle ────────────────────────────────────────────
onMounted(async () => {
await userStore.fetchProfile()
if (userStore.user) {
form.value = { nickname: userStore.user.nickname }
originalNickname.value = userStore.user.nickname
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
try {
if (!isFromLogin.value) {
await userStore.fetchProfile()
}
if (userStore.user) {
form.value = { nickname: userStore.user.nickname }
originalNickname.value = userStore.user.nickname
}
} finally {
initializing.value = false
}
})
</script>
<style lang="scss" scoped>
.info-page {
min-height: 100vh;
background: #f5f3f0;
}
/* ── Avatar section ──────────────────────────────────── */
.avatar-section {
display: flex;
flex-direction: column;
align-items: center;
padding: 56rpx 0 40rpx;
background: #fff;
margin-bottom: 24rpx;
border-bottom: 1rpx solid #f0ece8;
}
.info-page { min-height: 100vh; box-sizing: border-box; padding-bottom: calc(32rpx + env(safe-area-inset-bottom)); background: #fbf9f6; color: #514943; }
.welcome-banner { margin: 28rpx 32rpx 0; }
.welcome-title { display: block; font-size: 32rpx; font-weight: 500; }
.welcome-desc { display: block; margin-top: 12rpx; font-size: 24rpx; color: #8b817b; line-height: 1.7; }
.avatar-section { margin: 28rpx 32rpx 0; padding: 28rpx; border-radius: 28rpx; background: #f2e9e3; }
.avatar-btn {
background: transparent;
border: none;
padding: 0;
margin: 0;
&::after {
border: none;
}
}
.avatar-wrap {
position: relative;
width: 160rpx;
height: 160rpx;
margin-bottom: 16rpx;
}
.avatar {
width: 160rpx;
height: 160rpx;
border-radius: 50%;
border: 4rpx solid #f0f0f0;
}
.avatar-placeholder {
width: 160rpx;
height: 160rpx;
border-radius: 50%;
background: linear-gradient(135deg, #c9a87c, #e8c88a);
display: flex;
align-items: center;
justify-content: center;
}
.avatar-placeholder-text {
font-size: 64rpx;
font-weight: 700;
color: #fff;
}
.avatar-name {
font-size: 34rpx;
font-weight: 700;
color: #1a1a1a;
margin-bottom: 6rpx;
}
.avatar-hint {
font-size: 22rpx;
color: #bbb;
}
/* ── Form card ───────────────────────────────────────── */
.form-card {
background: #fff;
border-radius: 20rpx;
margin: 0 24rpx 20rpx;
overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
}
.form-row {
display: flex;
flex-direction: row;
align-items: center;
padding: 32rpx 28rpx;
border-bottom: 1rpx solid #f5f5f5;
min-height: 100rpx;
&--last {
border-bottom: none;
}
}
.form-label {
font-size: 28rpx;
color: #555;
width: 120rpx;
flex-shrink: 0;
font-weight: 500;
}
.form-input {
flex: 1;
font-size: 28rpx;
color: #222;
text-align: right;
background: transparent;
min-height: 44rpx;
}
.form-value {
flex: 1;
font-size: 28rpx;
color: #888;
text-align: right;
}
.form-arrow {
font-size: 36rpx;
color: #ccc;
margin-left: 8rpx;
line-height: 1;
}
/* Bind phone button (styled, not default wx button) */
.bind-phone-btn {
flex: 1;
display: flex;
justify-content: flex-end;
align-items: center;
background: transparent;
border: none;
padding: 0;
margin: 0;
line-height: normal;
/* reset uni button default styles */
&::after {
border: none;
}
}
.bind-phone-text {
font-size: 26rpx;
color: #c9a87c;
font-weight: 600;
text-decoration: underline;
}
/* ── Read-only info card ──────────────────────────────── */
.info-card {
background: #fff;
border-radius: 20rpx;
margin: 0 24rpx 32rpx;
overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
}
.info-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 28rpx 28rpx;
border-bottom: 1rpx solid #f5f5f5;
&--last {
border-bottom: none;
}
}
.info-label {
font-size: 26rpx;
color: #999;
}
.info-value {
font-size: 26rpx;
color: #555;
font-weight: 500;
}
/* ── Save button ─────────────────────────────────────── */
.save-wrap {
padding: 8rpx 24rpx 48rpx;
}
.save-btn {
width: 100%;
height: 96rpx;
border-radius: 48rpx;
background: linear-gradient(90deg, #1a1a2e, #2d2d5e);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 20rpx rgba(26, 26, 46, 0.3);
transition: opacity 0.2s;
&:active {
opacity: 0.85;
}
&--loading,
&--disabled {
opacity: 0.45;
box-shadow: none;
}
}
.save-btn-text {
font-size: 32rpx;
font-weight: 700;
color: #c9a87c;
letter-spacing: 2rpx;
display: flex; align-items: center; gap: 24rpx; width: 100%; min-width: 0;
height: auto; margin: 0; padding: 0; border: none; background: transparent;
border-radius: 0; line-height: 1.4; text-align: left; color: #514943;
&::after { border: none; }
&[disabled] { background: transparent; color: #8b817b; }
}
.avatar-wrap { width: 120rpx; height: 120rpx; flex-shrink: 0; border-radius: 50%; overflow: hidden; box-sizing: border-box; border: 5rpx solid #fcf8f4; }
.avatar, .avatar-placeholder { display: block; width: 100%; height: 100%; }
.avatar-placeholder { display: flex; align-items: center; justify-content: center; background: #e0cec4; color: #7b6254; font-size: 44rpx; font-weight: 400; line-height: 1; }
.avatar-copy { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 12rpx; }
.avatar-title { font-size: 30rpx; font-weight: 500; }
.avatar-action { font-size: 24rpx; color: #8b7160; }
.avatar-arrow { flex-shrink: 0; font-size: 32rpx; color: #a38d7d; }
.avatar-hint { display: block; margin-top: 20rpx; font-size: 21rpx; color: #9a8577; }
.form-section { margin: 32rpx 32rpx 0; }
.section-title { display: block; margin-bottom: 18rpx; font-size: 28rpx; font-weight: 500; }
.form-card { padding: 0 28rpx; background: #fff; border: 1rpx solid #eee8e3; border-radius: 28rpx; }
.nickname-field { padding: 26rpx 0 20rpx; }
.field-heading { display: flex; align-items: center; justify-content: space-between; gap: 20rpx; }
.form-label { flex-shrink: 0; font-size: 26rpx; color: #786b61; }
.field-count { font-size: 20rpx; color: #a2978e; }
.form-input { display: block; box-sizing: border-box; width: 100%; min-width: 0; height: 76rpx; margin-top: 12rpx; padding: 0 18rpx; border-radius: 14rpx; background: #faf8f5; font-size: 27rpx; color: #514943; line-height: normal; text-align: left; }
.form-row { display: flex; align-items: center; gap: 24rpx; min-height: 104rpx; border-top: 1rpx solid #f0ebe6; }
.form-value { flex: 1; min-width: 0; text-align: right; font-size: 26rpx; color: #6f655e; }
.bind-phone-btn { margin: 0 0 0 auto; padding: 0 20rpx; height: 60rpx; line-height: 60rpx; flex-shrink: 0; border: none; border-radius: 999rpx; background: #edf2ec; color: #617d73; font-size: 24rpx; &::after { border: none; } }
.info-card { margin: 24rpx 32rpx 0; padding: 8rpx 28rpx; border-radius: 26rpx; background: #f3f0eb; }
.info-row { display: flex; align-items: baseline; justify-content: space-between; gap: 20rpx; padding: 18rpx 0; }
.info-label { flex-shrink: 0; font-size: 23rpx; color: #8b817b; }
.info-value { font-size: 23rpx; color: #786b61; text-align: right; }
.save-wrap { margin: 32rpx 32rpx 0; }
.save-btn { display: block; width: 100%; height: 88rpx; line-height: 88rpx; margin: 0; padding: 0; border: none; border-radius: 999rpx; background: #6b8276; font-size: 28rpx; font-weight: 400; color: #fff; &::after { border: none; } &[disabled] { background: #e3e8df; color: #8a9586; } }
.save-hint { display: block; margin-top: 18rpx; font-size: 21rpx; color: #9a9086; text-align: center; }
.skip-btn { text-align: center; margin: 16rpx auto 0; padding: 8rpx 24rpx; width: auto; border: none; background: transparent; font-size: 24rpx; color: #8b817b; &::after { border: none; } }
</style>

View File

@@ -0,0 +1,19 @@
<template><view :style="{ paddingTop: navBarHeight }"><CustomNavBar title="邀请好友" show-back /><InviteCard /></view></template>
<script setup lang="ts">
import { onShareAppMessage, onShow } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import InviteCard from '../../components/InviteCard.vue'
import { useInviteStore } from '../../stores/invite'
import { useUserStore } from '../../stores/user'
import { getSystemLayout } from '../../utils/system'
const invite = useInviteStore()
const user = useUserStore()
const navBarHeight = `${getSystemLayout().navBarHeight}px`
onShow(() => {
if (user.loggedIn) {
invite.refresh().catch(() => {})
invite.refreshActivity().catch(() => {})
}
})
onShareAppMessage(() => ({ title: '送你 95 折购卡礼,一起练普拉提', path: invite.code ? `/pages/card/detail?showAll=1&inviteCode=${invite.code}` : '/pages/home/index' }))
</script>

View File

@@ -1,159 +1,110 @@
<template>
<view class="membership-page">
<!-- Pull-to-refresh scroll view -->
<scroll-view
class="scroll"
scroll-y
refresher-enabled
:refresher-triggered="refreshing"
@refresherrefresh="onRefresh"
>
<!-- Loading skeleton -->
<view v-if="loading && !refreshing" class="loading-wrap">
<view class="membership-page" :style="{ paddingTop: navBarHeight, height: pageHeight }">
<CustomNavBar title="我的会员卡" show-back />
<scroll-view class="scroll" scroll-y refresher-enabled :refresher-triggered="refreshing" @refresherrefresh="onRefresh">
<view v-if="loading && !refreshing && !allMemberships.length" class="loading-wrap">
<view v-for="i in 2" :key="i" class="skeleton-card" />
</view>
<!-- 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">
<text class="empty-title">还没有会员卡</text>
<text class="empty-sub">选一张适合自己的卡开始第一次练习</text>
<button class="empty-btn" @tap="goStore">选购会员卡</button>
</view>
</view>
<!-- Membership list -->
<view v-else class="list">
<!-- Active cards -->
<view v-if="activeMemberships.length > 0" class="group-section">
<view v-if="activeMemberships.length" 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>
<text class="group-title">正在使用</text>
<text class="group-count">{{ activeMemberships.length }} 张有效</text>
</view>
<view
v-for="m in activeMemberships"
:key="m.id"
class="card-item"
>
<!-- Colored left border strip -->
<view class="card-strip" :class="stripClass(m.cardType.type)" />
<!-- 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>
</view>
<view v-for="m in activeMemberships" :key="m.id" class="mc" :class="cardBgClass(m.cardType.type)">
<view class="mc-top">
<view class="mc-name-area">
<text class="mc-name">{{ m.cardType.name }}</text>
<text class="mc-type-text">{{ getCardTypeLabel(m.cardType.type) }}</text>
</view>
<view class="status-badge status-badge--active">
<text class="status-badge-text">有效</text>
<text class="mc-status mc-status--active">有效</text>
</view>
<view class="mc-balance">
<view class="mc-number-row">
<text class="mc-big-num">{{ m.remainingTimes !== null ? m.remainingTimes : daysRemaining(m) }}</text>
<text class="mc-big-unit">{{ m.remainingTimes !== null ? '次可用' : '天剩余' }}</text>
</view>
<text v-if="m.remainingTimes === null" class="mc-duration-note">有效期内不限次数</text>
<view v-else-if="getMembershipTotalTimes(m)" class="mc-progress">
<view class="mc-progress-track"><view class="mc-progress-fill" :style="{ width: getMembershipProgressWidth(m) }" /></view>
<text class="mc-progress-label">已用 {{ getMembershipUsedTimes(m) }} / {{ getMembershipTotalTimes(m) }} </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>
</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>
<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="info-row">
<text class="info-label">开始日期</text>
<text class="info-value">{{ m.startDate.slice(0, 10) }}</text>
<view class="mc-date-item mc-date-item--end">
<text class="mc-date-label">有效期至</text>
<text class="mc-date-value">{{ m.expireDate.slice(0, 10) }}</text>
</view>
</view>
<view class="mc-actions">
<button v-if="canRenewMembership(m)" class="mc-renew" @tap="goRenew(m)">续卡</button>
<button class="mc-book" @tap="goBooking">预约课程</button>
</view>
</view>
</view>
<!-- Expired / used up cards -->
<view v-if="inactiveMemberships.length > 0" class="group-section">
<view v-if="inactiveMemberships.length" 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>
<view
v-for="m in inactiveMemberships"
:key="m.id"
class="card-item card-item--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>
</view>
<view class="status-badge" :class="statusBadgeClass(m.status)">
<text class="status-badge-text">{{ statusLabel(m.status) }}</text>
<view v-for="m in inactiveMemberships" :key="m.id" class="mc mc--inactive">
<view class="mc-top">
<view class="mc-name-area">
<text class="mc-name">{{ m.cardType.name }}</text>
<text class="mc-type-text">{{ getCardTypeLabel(m.cardType.type) }}</text>
</view>
<text class="mc-status" :class="inactiveStatusClass(m.status)">{{ statusLabel(m.status) }}</text>
</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>
<view class="info-row">
<text class="info-label">有效期至</text>
<text class="info-value">{{ m.expireDate.slice(0, 10) }}</text>
<view class="mc-history-bottom">
<view class="mc-history-info">
<text v-if="m.remainingTimes !== null" class="mc-date-label">剩余 {{ m.remainingTimes }} </text>
<text class="mc-date-label">{{ m.expireDate.slice(0, 10) }} 到期</text>
</view>
<button v-if="canRenewMembership(m)" class="mc-renew mc-renew--inactive" @tap="goRenew(m)">续同款</button>
</view>
</view>
</view>
</view>
<view class="scroll-bottom-spacer" />
</scroll-view>
<!-- Buy more FAB -->
<view class="fab" @tap="goStore">
<text class="fab-icon">+</text>
<text class="fab-text">购买会员卡</text>
<view v-if="allMemberships.length" class="purchase-dock">
<button class="purchase-btn" @tap="goStore">选购会员卡</button>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed } from 'vue'
import { onShow, onResize } from '@dcloudio/uni-app'
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, getMembershipTotalTimes } from '../../utils/format'
import CustomNavBar from '../../components/CustomNavBar.vue'
const userStore = useUserStore()
// ─── State ────────────────────────────────────────────────
const navBarHeight = `${getSystemLayout().navBarHeight}px`
const pageHeight = ref(`${uni.getWindowInfo().windowHeight}px`)
onResize(() => { pageHeight.value = `${uni.getWindowInfo().windowHeight}px` })
const loading = ref(false)
const refreshing = ref(false)
// ─── Computed from store ───────────────────────────────────
const allMemberships = computed(() => userStore.memberships as MembershipWithCardType[])
const activeMemberships = computed(() =>
@@ -164,16 +115,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 +124,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 {
@@ -226,333 +153,80 @@ async function loadMemberships() {
async function onRefresh() {
refreshing.value = true
await userStore.fetchMemberships()
refreshing.value = false
try {
await userStore.fetchMemberships()
} finally {
refreshing.value = false
}
}
function goBooking() {
uni.switchTab({ url: '/pages/booking/index' })
}
function goStore() {
uni.$emit('scrollToCardShop')
uni.switchTab({ url: '/pages/home/index' })
}
// ─── Lifecycle ────────────────────────────────────────────
onMounted(loadMemberships)
function canRenewMembership(m: MembershipWithCardType): boolean {
return m.cardType.isActive && m.cardType.type !== CardTypeCategory.TRIAL
}
function goRenew(m: MembershipWithCardType) {
uni.navigateTo({ url: `/pages/card/detail?id=${m.cardTypeId}` })
}
onShow(loadMemberships)
</script>
<style lang="scss" scoped>
.membership-page {
min-height: 100vh;
background: #f5f3f0;
}
.scroll {
height: 100vh;
}
/* ── Loading ─────────────────────────────────────────── */
.loading-wrap {
padding: 24rpx;
display: flex;
flex-direction: column;
gap: 20rpx;
}
.skeleton-card {
height: 220rpx;
border-radius: 20rpx;
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; }
}
/* ── Empty ───────────────────────────────────────────── */
.empty-wrap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 40rpx;
gap: 20rpx;
}
.empty-icon {
font-size: 80rpx;
}
.empty-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.empty-sub {
font-size: 26rpx;
color: #999;
}
.empty-btn {
margin-top: 12rpx;
padding: 22rpx 60rpx;
border-radius: 44rpx;
background: #c9a87c;
box-shadow: 0 4rpx 16rpx rgba(201, 168, 124, 0.35);
}
.empty-btn-text {
font-size: 30rpx;
color: #fff;
font-weight: 600;
}
/* ── List ────────────────────────────────────────────── */
.list {
padding: 24rpx 24rpx 0;
}
/* ── Group section ───────────────────────────────────── */
.group-section {
margin-bottom: 8rpx;
}
.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; }
}
.group-title {
font-size: 26rpx;
color: #555;
font-weight: 600;
flex: 1;
}
.group-count {
font-size: 22rpx;
color: #bbb;
}
/* ── Card item ───────────────────────────────────────── */
.card-item {
background: #fff;
border-radius: 20rpx;
overflow: hidden;
margin-bottom: 16rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.07);
display: flex;
flex-direction: column;
&--inactive {
opacity: 0.72;
}
}
/* 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;
display: flex;
flex-direction: row;
align-items: center;
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; }
}
.card-header-left {
display: flex;
flex-direction: column;
gap: 8rpx;
}
.card-name {
font-size: 32rpx;
font-weight: 700;
color: #fff;
&--dim { color: #ddd; }
}
.card-type-badge {
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);
}
}
.card-type-badge-text {
font-size: 20rpx;
color: rgba(255, 255, 255, 0.85);
font-weight: 500;
}
/* Status badge */
.status-badge {
padding: 8rpx 20rpx;
border-radius: 20rpx;
border: 1rpx solid rgba(255, 255, 255, 0.35);
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 {
font-size: 22rpx;
color: #fff;
font-weight: 600;
}
/* Card body */
.card-body {
padding: 20rpx 28rpx 24rpx;
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;
}
.info-label {
font-size: 26rpx;
color: #999;
}
.info-value {
font-size: 26rpx;
color: #333;
font-weight: 500;
}
/* ── Progress bar ────────────────────────────────────── */
.progress-wrap {
display: flex;
flex-direction: column;
gap: 8rpx;
margin-bottom: 6rpx;
}
.progress-bar {
height: 8rpx;
background: #f0f0f0;
border-radius: 4rpx;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #c9a87c, #e8c88a);
border-radius: 4rpx;
transition: width 0.4s ease;
}
.progress-label {
font-size: 22rpx;
color: #bbb;
text-align: right;
}
/* ── FAB ─────────────────────────────────────────────── */
.fab {
position: fixed;
bottom: calc(32rpx + env(safe-area-inset-bottom));
right: 32rpx;
background: #1a1a2e;
border-radius: 44rpx;
padding: 22rpx 36rpx;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.2);
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;
}
.fab-text {
font-size: 28rpx;
font-weight: 700;
color: #c9a87c;
letter-spacing: 1rpx;
}
/* ── Spacer ──────────────────────────────────────────── */
.scroll-bottom-spacer {
height: 120rpx;
}
.membership-page { box-sizing: border-box; height: 100vh; display: flex; flex-direction: column; background: #fbf9f6; color: #514943; }
.scroll { flex: 1; height: 0; min-height: 0; }
.loading-wrap { padding: 28rpx 32rpx; display: flex; flex-direction: column; gap: 24rpx; }
.skeleton-card { height: 420rpx; border-radius: 28rpx; background: linear-gradient(90deg, #f0eae5 25%, #faf7f3 50%, #f0eae5 75%); background-size: 400% 100%; animation: shimmer 1.4s infinite; }
.empty-wrap { padding: 40rpx 32rpx; }
.empty-card { padding: 48rpx 32rpx; border-radius: 28rpx; background: #f2e9e3; display: flex; flex-direction: column; align-items: flex-start; gap: 16rpx; }
.empty-title { font-size: 32rpx; font-weight: 500; }
.empty-sub { font-size: 24rpx; color: #8b7b70; line-height: 1.7; }
.empty-btn { margin: 12rpx 0 0; padding: 0 32rpx; height: 76rpx; line-height: 76rpx; border: none; border-radius: 999rpx; background: #6b8276; color: #fff; font-size: 26rpx; &::after { border: none; } }
.list { padding: 28rpx 32rpx 0; }
.group-section + .group-section { margin-top: 36rpx; }
.group-header { display: flex; align-items: baseline; justify-content: space-between; gap: 20rpx; margin-bottom: 20rpx; }
.group-title { font-size: 28rpx; font-weight: 500; }
.group-count { font-size: 22rpx; color: #8b817b; }
.mc { --balance-bg: #f3ebe3; --balance-ink: #8b6c5b; padding: 28rpx; margin-bottom: 20rpx; border: 1rpx solid #eee8e3; border-radius: 28rpx; background: #fff; }
.mc--duration { --balance-bg: #edf2e9; --balance-ink: #617d63; }
.mc--trial { --balance-bg: #f5e9e5; --balance-ink: #9b7768; }
.mc-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 20rpx; }
.mc-name-area { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
.mc-name { font-size: 30rpx; font-weight: 500; line-height: 1.5; overflow-wrap: anywhere; }
.mc-type-text { font-size: 22rpx; color: #8b817b; }
.mc-status { flex-shrink: 0; padding: 6rpx 16rpx; border-radius: 999rpx; background: #ede9e4; color: #8b817b; font-size: 21rpx; line-height: 1.4; }
.mc-status--active { background: #edf2ec; color: #617d73; }
.mc-balance { margin-top: 24rpx; padding: 22rpx 24rpx; background: var(--balance-bg); border-radius: 20rpx; }
.mc-number-row { display: flex; align-items: baseline; gap: 10rpx; }
.mc-big-num { font-size: 56rpx; font-weight: 400; line-height: 1.1; color: var(--balance-ink); font-variant-numeric: tabular-nums; }
.mc-big-unit { font-size: 23rpx; color: var(--balance-ink); }
.mc-duration-note { display: block; font-size: 21rpx; color: #7b8775; margin-top: 12rpx; }
.mc-progress { margin-top: 18rpx; }
.mc-progress-track { height: 6rpx; border-radius: 6rpx; overflow: hidden; background: rgba(255,255,255,0.8); }
.mc-progress-fill { height: 100%; border-radius: 6rpx; background: #b9a38f; }
.mc-progress-label { display: block; margin-top: 10rpx; font-size: 21rpx; color: #8b7b70; }
.mc-bottom { display: flex; justify-content: space-between; gap: 20rpx; margin-top: 22rpx; }
.mc-date-item { min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
.mc-date-item--end { text-align: right; }
.mc-date-label { font-size: 21rpx; color: #8b817b; line-height: 1.6; }
.mc-date-value { font-size: 24rpx; color: #6f655e; font-variant-numeric: tabular-nums; }
.mc-actions { display: flex; gap: 16rpx; margin-top: 26rpx; }
.mc-renew, .mc-book { flex: 1; margin: 0; padding: 0 24rpx; height: 72rpx; line-height: 72rpx; border: none; border-radius: 999rpx; font-size: 25rpx; font-weight: 400; &::after { border: none; } }
.mc-renew { background: #f3eee8; color: #8b7160; }
.mc-book { background: #6b8276; color: #fff; }
.mc--inactive { background: #f4f1ed; border-color: #eee8e3; .mc-name { font-size: 27rpx; font-weight: 400; color: #786d64; } }
.mc-history-bottom { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; margin-top: 22rpx; }
.mc-history-info { display: flex; flex-direction: column; gap: 4rpx; }
.mc-renew--inactive { flex: none; flex-shrink: 0; height: 64rpx; line-height: 64rpx; background: #fffaf5; font-size: 23rpx; }
.purchase-dock { flex-shrink: 0; padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom)); border-top: 1rpx solid #eee8e3; background: #fbf9f6; }
.purchase-btn { display: block; width: 100%; margin: 0; padding: 0; height: 84rpx; line-height: 84rpx; border: none; border-radius: 999rpx; background: #6b8276; color: #fff; font-size: 28rpx; font-weight: 400; &::after { border: none; } }
.scroll-bottom-spacer { height: 20rpx; }
</style>

View File

@@ -0,0 +1,249 @@
<template>
<view class="schedule-page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="我的课表" show-back />
<view class="calendar">
<view class="calendar__heading">
<picker mode="date" :value="selectedDate" @change="handlePickerChange">
<view class="calendar__month">{{ monthLabel }}<text class="calendar__chevron"></text></view>
</picker>
<button v-if="!isToday(selectedDate)" class="text-button" @tap="selectDate(formatDate(new Date()))">回到今天</button>
<text v-else class="calendar__today">今天</text>
</view>
<view class="week-navigation">
<button class="week-arrow" aria-label="上一周" @tap="shiftWeek(-7)"></button>
<text class="week-navigation__label">{{ weekLabel }}</text>
<button class="week-arrow" aria-label="下一周" @tap="shiftWeek(7)"></button>
</view>
<view class="week">
<button v-for="day in weekDays" :key="day.date" class="day"
:class="{ 'day--selected': day.date === selectedDate, 'day--today': isToday(day.date) }"
:aria-label="`${day.date} ${day.label}${day.date === selectedDate ? '已选中' : ''}`"
@tap="selectDate(day.date)">
<text class="day__label">{{ day.label }}</text>
<text class="day__number">{{ day.number }}</text>
<view class="day__dot" />
</button>
</view>
</view>
<view class="agenda-heading">
<text class="agenda-heading__date">{{ dateLabel }}</text>
<text class="agenda-heading__count">{{ loading ? '正在加载' : error ? '加载失败' : `${slots.length} 节课 · ${studentCount} 人次` }}</text>
</view>
<scroll-view class="schedule-scroll" scroll-y refresher-enabled :refresher-triggered="refreshing"
:scroll-top="scrollTop" @scroll="handleScroll" @refresherrefresh="handleRefresh">
<view v-if="loading" class="skeleton" aria-label="正在加载课表">
<view v-for="i in 3" :key="i" class="skeleton__row"><view class="skeleton__time" /><view class="skeleton__body" /></view>
</view>
<view v-else-if="error" class="empty">
<text class="empty__title">课表暂时未能加载</text>
<text class="empty__description">{{ error }}</text>
<button class="outline-button" @tap="loadSchedule(selectedDate)">重新加载</button>
</view>
<view v-else-if="!loggedIn || !isAdmin" class="empty">
<text class="empty__title">{{ loggedIn ? '仅管理员可查看课表' : '请先登录' }}</text>
<text class="empty__description">返回我的查看账号信息</text>
</view>
<view v-else-if="slots.length === 0" class="empty">
<view class="empty__line" />
<text class="empty__title">当天暂无预约课程</text>
<text class="empty__description">选择其他日期查看授课安排</text>
<button v-if="!isToday(selectedDate)" class="outline-button" @tap="selectDate(formatDate(new Date()))">查看今天</button>
</view>
<view v-else class="agenda">
<view v-for="slot in slots" :key="slot.slotId" class="session">
<view class="session__time">
<text class="session__start">{{ slot.startTime.slice(0, 5) }}</text>
<text class="session__end">{{ slot.endTime.slice(0, 5) }} 结束</text>
</view>
<view class="session__roster">
<view class="session__heading"><text>预约学员</text><text>{{ slot.students.length }} </text></view>
<view v-for="student in slot.students" :key="student.bookingId" class="student">
<view class="student__headline">
<text class="student__name">{{ student.nickname || '未命名学员' }}</text>
<text class="student__status" :class="`student__status--${student.status.toLowerCase()}`">{{ statusLabel(student.status) }}</text>
</view>
<button v-if="student.phone" class="student__contact" :aria-label="`联系${student.nickname || '学员'}`" @tap="contactStudent(student.phone)">
<text>{{ formatPhone(student.phone) }}</text><text class="student__contact-label">联系 </text>
</button>
<text v-else class="student__no-phone">未留手机号</text>
</view>
</view>
</view>
<text class="agenda__end">当天课程已全部显示</text>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { storeToRefs } from 'pinia'
import { BookingStatus, type TeachingScheduleSlot } from '@mp-pilates/shared'
import CustomNavBar from '../../components/CustomNavBar.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 { loggedIn, isAdmin } = storeToRefs(useUserStore())
const navBarHeight = `${getSystemLayout().navBarHeight}px`
const selectedDate = ref(formatDate(new Date()))
// Keep the rendered date and request result paired when dates are changed quickly.
const slots = ref<TeachingScheduleSlot[]>([])
const loading = ref(false)
const refreshing = ref(false)
const error = ref('')
const scrollTop = ref(0)
let currentScrollTop = 0
let requestId = 0
function parseDate(value: string) {
const [year, month, day] = value.split('-').map(Number)
return new Date(year, month - 1, day)
}
const monthLabel = computed(() => `${selectedDate.value.slice(0, 4)}${Number(selectedDate.value.slice(5, 7))}`)
const dateLabel = computed(() => `${isToday(selectedDate.value) ? '今天 · ' : ''}${Number(selectedDate.value.slice(5, 7))}${Number(selectedDate.value.slice(8, 10))}`)
const studentCount = computed(() => slots.value.reduce((sum, slot) => sum + slot.students.length, 0))
const weekDays = computed(() => {
const start = parseDate(selectedDate.value)
start.setDate(start.getDate() - (start.getDay() + 6) % 7)
return Array.from({ length: 7 }, (_, index) => {
const day = new Date(start)
day.setDate(start.getDate() + index)
const date = formatDate(day)
return { date, number: day.getDate(), label: getWeekdayLabel(date).replace('周', '').replace('星期', '') }
})
})
const weekLabel = computed(() => {
const first = parseDate(weekDays.value[0].date)
const last = parseDate(weekDays.value[6].date)
return `${first.getMonth() + 1}${first.getDate()}日 — ${last.getMonth() + 1}${last.getDate()}`
})
onShow(() => { loadSchedule(selectedDate.value) })
function handlePickerChange(event: { detail: { value: string } }) {
selectDate(event.detail.value)
}
function shiftWeek(days: number) {
const date = parseDate(selectedDate.value)
date.setDate(date.getDate() + days)
selectDate(formatDate(date))
}
function handleScroll(event: { detail: { scrollTop: number } }) {
currentScrollTop = event.detail.scrollTop
}
async function selectDate(date: string) {
if (selectedDate.value === date) return
selectedDate.value = date
refreshing.value = false
scrollTop.value = currentScrollTop
await nextTick()
scrollTop.value = 0
loadSchedule(date)
}
async function handleRefresh() {
if (refreshing.value) return
refreshing.value = true
try { await loadSchedule(selectedDate.value) }
finally { refreshing.value = false }
}
async function loadSchedule(date: string) {
const id = ++requestId
error.value = ''
slots.value = []
if (!loggedIn.value || !isAdmin.value) { loading.value = false; return }
loading.value = true
try {
const result = await bookingStore.fetchTeachingSchedule(date)
if (id === requestId) slots.value = [...result].sort((a, b) => a.startTime.localeCompare(b.startTime))
} catch (err: unknown) {
if (id === requestId) error.value = getErrorMessage(err, '请检查网络后重试')
} finally {
if (id === requestId) loading.value = false
}
}
function formatPhone(phone: string) {
return /^\d{11}$/.test(phone) ? `${phone.slice(0, 3)} ${phone.slice(3, 7)} ${phone.slice(7)}` : phone
}
function contactStudent(phone: string) {
uni.makePhoneCall({ phoneNumber: phone })
}
const STATUS_LABELS: Record<BookingStatus, string> = {
[BookingStatus.PENDING_CONFIRMATION]: '待确认',
[BookingStatus.CONFIRMED]: '已确认',
[BookingStatus.CANCELLED]: '已取消',
[BookingStatus.COMPLETED]: '已完成',
[BookingStatus.NO_SHOW]: '未出席',
}
function statusLabel(status: BookingStatus) { return STATUS_LABELS[status] ?? status }
</script>
<style lang="scss" scoped>
.schedule-page {
height: 100vh;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
background: #fbf9f6;
color: #514943;
}
button { margin: 0; padding: 0; background: transparent; font-weight: 400; border-radius: 0; &::after { border: none; } }
.calendar { flex-shrink: 0; padding: 22rpx 32rpx 20rpx; background: #fff; }
.calendar__heading { display: flex; align-items: center; justify-content: space-between; min-height: 76rpx; }
.calendar__month { padding: 16rpx 0; font-size: 36rpx; font-family: 'Songti SC', 'STSong', serif; }
.calendar__chevron { margin-left: 16rpx; font-size: 26rpx; color: #81776f; }
.text-button, .calendar__today { font-size: 24rpx; color: #526e62; }
.text-button { line-height: 76rpx; padding-left: 24rpx; }
.week-navigation { display: flex; align-items: center; justify-content: space-between; margin: 0 -12rpx 8rpx; }
.week-navigation__label { font-size: 23rpx; color: #81776f; }
.week-arrow { width: 80rpx; height: 76rpx; line-height: 70rpx; font-size: 40rpx; color: #70665e; }
.week { display: flex; justify-content: space-between; gap: 6rpx; }
.day { flex: 1; min-width: 0; height: 120rpx; display: flex; flex-direction: column; align-items: center; justify-content: center; line-height: 1; border-radius: 40rpx; color: #514943; }
.day__label { font-size: 22rpx; color: #81776f; }
.day__number { margin-top: 16rpx; font-size: 32rpx; font-variant-numeric: tabular-nums; }
.day__dot { margin-top: 10rpx; height: 6rpx; width: 6rpx; border-radius: 50%; background: transparent; }
.day--today .day__dot { background: #526e62; }
.day--selected { background: #526e62; color: #fff; .day__label { color: #fff; } .day__dot { background: transparent; } }
.day--selected.day--today .day__dot { background: #fff; }
.agenda-heading { flex-shrink: 0; display: flex; justify-content: space-between; align-items: baseline; gap: 16rpx; padding: 32rpx; border-top: 1rpx solid #eee9e2; }
.agenda-heading__date { font-size: 28rpx; font-weight: 500; }
.agenda-heading__count { font-size: 23rpx; color: #81776f; }
.schedule-scroll { flex: 1; min-height: 0; height: 0; }
.agenda { padding: 0 32rpx calc(40rpx + env(safe-area-inset-bottom)); }
.session { margin-bottom: 24rpx; padding: 0 28rpx; overflow: hidden; background: #fff; border: 1rpx solid #deded5; border-radius: 20rpx; }
.session__time { display: flex; align-items: baseline; gap: 20rpx; margin: 0 -28rpx; padding: 24rpx 28rpx; background: #eef2ed; border-bottom: 1rpx solid #dde4da; }
.session__start { display: block; font-size: 36rpx; font-variant-numeric: tabular-nums; font-weight: 500; }
.session__end { font-size: 24rpx; color: #687367; }
.session__roster { min-width: 0; }
.session__heading { display: flex; justify-content: space-between; padding: 24rpx 0 4rpx; font-size: 23rpx; color: #766d64; }
.student { padding: 24rpx 0 16rpx; border-bottom: 1rpx solid #e8e2da; }
.student:last-child { border-bottom: none; }
.student__headline { display: flex; align-items: baseline; justify-content: space-between; gap: 12rpx; }
.student__name { min-width: 0; font-size: 30rpx; font-weight: 500; line-height: 1.5; overflow-wrap: anywhere; word-break: break-all; }
.student__status { flex-shrink: 0; padding: 6rpx 12rpx; border-radius: 6rpx; background: #f2f0ec; font-size: 22rpx; line-height: 1.4; color: #766d64; }
.student__status--confirmed { background: #edf3ed; color: #526e62; }
.student__status--pending_confirmation { background: #f8f0e3; color: #956b37; }
.student__status--no_show { background: #f8eeea; color: #a06456; }
.student__contact { width: 100%; min-height: 76rpx; line-height: 1.4; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8rpx; text-align: left; font-size: 23rpx; color: #81776f; font-variant-numeric: tabular-nums; }
.student__contact-label { color: #526e62; font-size: 22rpx; }
.student__no-phone { display: block; padding: 18rpx 0; font-size: 23rpx; color: #81776f; }
.agenda__end { display: block; padding: 12rpx 0 24rpx; text-align: center; color: #81776f; font-size: 21rpx; }
.empty { padding: 100rpx 48rpx 48rpx; display: flex; flex-direction: column; align-items: center; text-align: center; }
.empty__line { height: 48rpx; width: 1rpx; background: #b4c1b7; margin-bottom: 32rpx; }
.empty__title { font-family: 'Songti SC', 'STSong', serif; font-size: 34rpx; }
.empty__description { margin-top: 20rpx; font-size: 25rpx; color: #81776f; line-height: 1.7; }
.outline-button { margin-top: 36rpx; padding: 0 36rpx; min-height: 80rpx; line-height: 80rpx; border: 1rpx solid #bcc7bf; border-radius: 8rpx; color: #526e62; font-size: 25rpx; }
.skeleton { padding: 12rpx 32rpx; }
.skeleton__row { display: flex; gap: 24rpx; margin-bottom: 40rpx; }
.skeleton__time { width: 116rpx; height: 40rpx; background: #eae6df; border-radius: 4rpx; }
.skeleton__body { flex: 1; height: 180rpx; background: #eeebe5; border-radius: 4rpx; }
button:active { opacity: .7; }
</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,7 @@ import { defineStore } from 'pinia'
import { ref } from 'vue'
import { get, post, put, del } from '../utils/request'
import type {
WeekTemplate,
WeekTemplateInput,
TeachingAnalytics,
CardType,
CreateCardTypeDto,
UpdateCardTypeDto,
@@ -13,40 +12,78 @@ import type {
TimeSlot,
CreateManualSlotDto,
PaginatedData,
ScheduleSlotPreview,
PublishDaySlotsDto,
FlashSaleAdminItem,
CreateFlashSaleDto,
UpdateFlashSaleDto,
CreateStudioUploadCredentialDto,
StudioUploadCredential,
AdminMemberSummary,
AdminMemberDetail,
LessonSupplementRecord,
CreateLessonSupplementDto,
UpdateAdminMemberProfileDto,
AdminArrangeBookingDto,
MembershipWithCardType,
BookingWithDetails,
} 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
totalBookings: number
}
export interface MemberSummary {
export type MemberSummary = AdminMemberSummary
export interface UserMembership {
userId: string
nickname: string
phone: string | null
avatarUrl: string | null
totalBookings: number
completedBookings: number
cancelledBookings: number
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,64 @@ 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 fetchLessonSupplements(userId: string): Promise<LessonSupplementRecord[]> {
return get<LessonSupplementRecord[]>(`/admin/members/${userId}/lesson-supplements`)
}
async function createLessonSupplement(userId: string, dto: CreateLessonSupplementDto): Promise<LessonSupplementRecord> {
return post<LessonSupplementRecord>(`/admin/members/${userId}/lesson-supplements`, { ...dto })
}
async function revokeLessonSupplement(userId: string, id: string): Promise<void> {
await post(`/admin/members/${userId}/lesson-supplements/${id}/revoke`)
}
async function fetchMemberDetail(userId: string): Promise<AdminMemberDetail> {
return get<AdminMemberDetail>(`/admin/members/${userId}`)
}
async function updateMemberProfile(
userId: string,
dto: UpdateAdminMemberProfileDto,
): Promise<AdminMemberDetail> {
return put<AdminMemberDetail>(`/admin/members/${userId}`, dto as unknown as Record<string, unknown>)
}
async function arrangeMemberBooking(dto: AdminArrangeBookingDto): Promise<BookingWithDetails> {
return post<BookingWithDetails>('/admin/bookings', dto as unknown as Record<string, unknown>)
}
async function updateUserMembership(
userId: string,
dto: {
membershipId?: string
cardTypeId: string
remainingTimes?: number | null
startDate: string
expireDate: string
},
): Promise<MembershipWithCardType> {
return put<MembershipWithCardType>(`/admin/members/${userId}/membership`, dto)
}
async function deleteUserMembership(userId: string, membershipId: string): Promise<void> {
return del<void>(
`/admin/members/${userId}/membership?membershipId=${encodeURIComponent(membershipId)}`,
)
}
// ── Time slots ───────────────────────────────────────────────────
@@ -121,7 +228,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 +239,66 @@ 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 previewScheduleByDate(date)
schedulePreview.value = data
return data
} finally {
scheduleLoading.value = false
}
}
async function previewScheduleByDate(date: string): Promise<ScheduleSlotPreview[]> {
return get<ScheduleSlotPreview[]>('/admin/schedule/preview', { date })
}
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}`)
}
async function fetchTeachingAnalytics(month: string): Promise<TeachingAnalytics> {
return get<TeachingAnalytics>('/admin/teaching-analytics', { month })
}
return {
fetchTeachingAnalytics,
// State
weekTemplates,
cardTypes,
studioConfig,
// Week templates
fetchWeekTemplates,
saveWeekTemplates,
schedulePreview,
scheduleLoading,
// Card types
fetchCardTypes,
createCardType,
@@ -153,18 +307,37 @@ export const useAdminStore = defineStore('admin', () => {
// Studio
fetchStudioConfig,
saveStudioConfig,
createStudioUploadCredential,
// Orders
fetchAdminOrders,
// Bookings
fetchAdminBookings,
// Members
fetchMembers,
fetchMemberDetail,
fetchLessonSupplements,
createLessonSupplement,
revokeLessonSupplement,
updateMemberProfile,
arrangeMemberBooking,
getUserMembership,
updateUserMembership,
deleteUserMembership,
// Time slots
fetchSlotsByDate,
createManualSlot,
closeSlot,
generateSlots,
// Schedule
fetchSchedulePreview,
previewScheduleByDate,
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
@@ -31,41 +44,138 @@ export const useBookingStore = defineStore('booking', () => {
return result
}
/**
* Replace a booking in `myBookings` by id. Preserves immutability: always
* returns a new array reference so Vue's computed/watchers pick up the change.
* If the booking isn't in the list (e.g. paginated out), leaves state untouched.
*/
function replaceBooking(updated: BookingWithDetails) {
const idx = myBookings.value.findIndex((b) => b.id === updated.id)
if (idx === -1) return
const next = myBookings.value.slice()
next[idx] = updated
myBookings.value = next
}
async function cancelBooking(bookingId: string) {
const result = await put<BookingWithDetails>(`/booking/${bookingId}/cancel`)
replaceBooking(result)
return result
}
async function fetchMyBookings(status?: string) {
loadingBookings.value = true
async function fetchMyBookings(status?: string, opts: { silent?: boolean } = {}) {
if (!opts.silent) 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
if (!opts.silent) loadingBookings.value = false
}
}
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,
})
replaceBooking(result)
return result
}
async function completeBooking(bookingId: string, remark?: string) {
const result = await put<BookingWithDetails>(`/booking/${bookingId}/complete`, {
remark,
})
replaceBooking(result)
return result
}
async function markNoShow(bookingId: string, remark?: string) {
const result = await put<BookingWithDetails>(`/booking/${bookingId}/noshow`, {
remark,
})
replaceBooking(result)
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,
replaceBooking,
}
})

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,55 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { InviteActivitySummary } from '@mp-pilates/shared'
import { get, post } from '../utils/request'
export const useInviteStore = defineStore('invite', () => {
const activity = ref<InviteActivitySummary | null>(null)
let activityRevision = 0
async function refreshActivity() {
const requestRevision = ++activityRevision
try {
const result = await get<InviteActivitySummary>('/invite/activity')
if (requestRevision === activityRevision) activity.value = result
} catch (err) {
if (requestRevision === activityRevision) activity.value = null
throw err
}
}
const code = ref('')
const eligible = ref(false)
const pendingCode = ref('')
let revision = 0
let confirming = false
async function refresh() {
if (confirming) return
const requestRevision = ++revision
const result = await get<{ inviteCode: string; discountEligible: boolean }>('/invite/code')
if (requestRevision !== revision) return
code.value = result.inviteCode
eligible.value = result.discountEligible
}
async function confirm(value: string) {
const requestRevision = ++revision
confirming = true
try {
const result = await post<{ inviteCode: string; discountEligible: boolean }>('/invite/confirm', { code: value })
if (requestRevision !== revision) throw new Error('登录状态已变化,请重试')
code.value = result.inviteCode
eligible.value = result.discountEligible
pendingCode.value = ''
} finally {
confirming = false
}
}
function reset() {
activityRevision++
activity.value = null
revision++
code.value = ''
eligible.value = false
pendingCode.value = ''
}
function price(value: number) { return eligible.value ? Math.round(value * 95 / 100) : value }
return { activity, refreshActivity, code, eligible, pendingCode, refresh, confirm, reset, price }
})

View File

@@ -1,3 +1,4 @@
import { useInviteStore } from './invite'
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type {
@@ -5,9 +6,20 @@ import type {
UserStatsResponse,
MembershipWithCardType,
} from '@mp-pilates/shared'
import { UserRole, MembershipStatus } from '@mp-pilates/shared'
import {
UserRole,
MembershipStatus,
getMembershipRenewalHint,
} from '@mp-pilates/shared'
import { wxLogin, isLoggedIn, logout as authLogout } from '../utils/auth'
import { setUnauthorizedHandler } from '../utils/session'
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 +38,8 @@ export const useUserStore = defineStore('user', () => {
memberships.value.filter((m) => m.status === MembershipStatus.ACTIVE),
)
const hasValidMembership = computed(() => activeMemberships.value.length > 0)
const renewalHint = computed(() => getMembershipRenewalHint(memberships.value))
const inviteShareEligible = computed(() => !!user.value?.inviteShareEligible)
// Actions
async function login() {
@@ -33,17 +47,36 @@ export const useUserStore = defineStore('user', () => {
const result = await wxLogin()
token.value = result.token
user.value = result.user
return result.user
await useInviteStore().refresh().catch(() => {})
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')
await useInviteStore().refresh().catch(() => {})
syncSubscriptionTemplates(user.value)
return user.value
} catch (err) {
console.error('Fetch profile failed:', err)
}
@@ -58,21 +91,29 @@ export const useUserStore = defineStore('user', () => {
}
}
async function fetchMemberships() {
if (!isLoggedIn()) return
async function fetchMemberships(): Promise<boolean> {
if (!isLoggedIn()) return false
try {
memberships.value = await get<MembershipWithCardType[]>('/membership/my')
return true
} catch (err) {
console.error('Fetch memberships failed:', err)
return false
}
}
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()
@@ -80,14 +121,22 @@ export const useUserStore = defineStore('user', () => {
}
}
function logout() {
authLogout()
function clearSession() {
token.value = ''
useInviteStore().reset()
user.value = null
stats.value = null
memberships.value = []
resetSubscriptionMessageTemplateCache()
}
function logout() {
authLogout()
clearSession()
}
setUnauthorizedHandler(clearSession)
return {
user,
stats,
@@ -98,11 +147,15 @@ export const useUserStore = defineStore('user', () => {
isAdmin,
activeMemberships,
hasValidMembership,
renewalHint,
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,13 @@
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 totalTimes?: number | null
readonly cardType: { readonly totalTimes: number | null }
}
/** 格式化金额:分 → 元 */
export function formatPrice(cents: number): string {
return (cents / 100).toFixed(2)
@@ -44,3 +54,124 @@ 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'
}
/** 会员卡累计购入次数(续卡后优先用卡上的快照) */
export function getMembershipTotalTimes(membership: MembershipLike): number | null {
return membership.totalTimes ?? membership.cardType.totalTimes ?? null
}
/** 会员卡进度百分比(剩余 / 总次数clamp 到 0~100% */
export function getMembershipProgressWidth(membership: MembershipLike): string {
const totalTimes = getMembershipTotalTimes(membership)
if (membership.remainingTimes === null || !totalTimes) return '0%'
const pct = (membership.remainingTimes / totalTimes) * 100
return `${Math.max(0, Math.min(100, pct))}%`
}
/** 已使用次数(不低于 0防止管理员调高剩余次数导致负值 */
export function getMembershipUsedTimes(membership: MembershipLike): number {
const totalTimes = getMembershipTotalTimes(membership)
if (membership.remainingTimes === null || !totalTimes) return 0
return Math.max(0, 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)}%`
}
/** 格式化日期时间为 YYYY-MM-DD HH:mm */
export function formatDateTimeFull(dateStr: string): string {
const d = new Date(dateStr)
if (Number.isNaN(d.getTime())) return '-'
const year = d.getFullYear()
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')
return `${year}-${month}-${day} ${hour}:${min}`
}
/** 格式化日期时间为 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,8 @@
import type { ApiResponse, PaginatedData } from '@mp-pilates/shared'
import { notifyUnauthorized } from './session'
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
@@ -19,6 +11,13 @@ interface RequestOptions {
readonly header?: Record<string, string>
}
export class HttpRequestError extends Error {
constructor(message: string, readonly statusCode: number) {
super(message)
this.name = 'HttpRequestError'
}
}
export function request<T>(options: RequestOptions): Promise<T> {
return new Promise((resolve, reject) => {
const token = uni.getStorageSync('token') as string
@@ -34,14 +33,15 @@ export function request<T>(options: RequestOptions): Promise<T> {
},
success: (res) => {
if (res.statusCode === 401) {
uni.removeStorageSync('token')
uni.showToast({ title: '请重新登录', icon: 'none' })
if (!options.url.startsWith('/auth/login')) {
notifyUnauthorized()
}
reject(new Error('Unauthorized'))
return
}
if (res.statusCode >= 400) {
const body = res.data as ApiResponse<unknown>
reject(new Error(body?.message || `请求失败 (${res.statusCode})`))
reject(new HttpRequestError(body?.message || `请求失败 (${res.statusCode})`, res.statusCode))
return
}
const body = res.data as ApiResponse<T>

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,67 @@
import {
DEFAULT_SCHEDULE_START_TIME,
DEFAULT_SCHEDULE_END_TIME,
SCHEDULE_MINUTE_STEP,
} from '@mp-pilates/shared'
function buildHourOptions(): string[] {
const startHour = parseInt(DEFAULT_SCHEDULE_START_TIME.slice(0, 2), 10)
const endHour = parseInt(DEFAULT_SCHEDULE_END_TIME.slice(0, 2), 10)
const list: string[] = []
for (let h = startHour; h <= endHour; h++) {
list.push(String(h).padStart(2, '0'))
}
return list
}
function buildMinuteOptions(): string[] {
const list: string[] = []
for (let m = 0; m < 60; m += SCHEDULE_MINUTE_STEP) {
list.push(String(m).padStart(2, '0'))
}
return list
}
export const SCHEDULE_HOUR_OPTIONS = buildHourOptions()
export const SCHEDULE_MINUTE_OPTIONS = buildMinuteOptions()
export const SCHEDULE_TIME_PICKER_RANGE = [SCHEDULE_HOUR_OPTIONS, SCHEDULE_MINUTE_OPTIONS]
export function timeToMinutes(time: string): number {
const [h, m] = time.split(':').map(Number)
return h * 60 + (m || 0)
}
export function minutesToTime(total: number): string {
const h = Math.floor(total / 60)
const m = total % 60
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
}
export function timeToPickerIndex(time: string): number[] {
let total = timeToMinutes(time || DEFAULT_SCHEDULE_START_TIME)
const minTotal = timeToMinutes(DEFAULT_SCHEDULE_START_TIME)
const maxTotal = timeToMinutes(DEFAULT_SCHEDULE_END_TIME)
total = Math.max(minTotal, Math.min(maxTotal, total))
total = Math.round(total / SCHEDULE_MINUTE_STEP) * SCHEDULE_MINUTE_STEP
total = Math.max(minTotal, Math.min(maxTotal, total))
const hour = String(Math.floor(total / 60)).padStart(2, '0')
const minute = String(total % 60).padStart(2, '0')
return [
Math.max(0, SCHEDULE_HOUR_OPTIONS.indexOf(hour)),
Math.max(0, SCHEDULE_MINUTE_OPTIONS.indexOf(minute)),
]
}
export function pickerIndexToTime(value: number[]): string {
const hour = SCHEDULE_HOUR_OPTIONS[value[0]] ?? SCHEDULE_HOUR_OPTIONS[0]
const minute = SCHEDULE_MINUTE_OPTIONS[value[1]] ?? SCHEDULE_MINUTE_OPTIONS[0]
return `${hour}:${minute}`
}
/** 将 "HH:mm" 加一小时,不超过当日最晚结束时间 21:30 */
export function addOneHourCapped(time: string): string {
const next = timeToMinutes(time) + 60
const cap = timeToMinutes(DEFAULT_SCHEDULE_END_TIME)
return minutesToTime(Math.min(next, cap))
}

View File

@@ -0,0 +1,22 @@
const UNAUTHORIZED_TOAST_GAP_MS = 2000
let unauthorizedHandler: (() => void) | null = null
let lastUnauthorizedAt = 0
export function setUnauthorizedHandler(handler: () => void): void {
unauthorizedHandler = handler
}
/** Clear persisted token and in-memory session after the server rejects auth. */
export function notifyUnauthorized(): void {
const now = Date.now()
const shouldToast = now - lastUnauthorizedAt > UNAUTHORIZED_TOAST_GAP_MS
lastUnauthorizedAt = now
uni.removeStorageSync('token')
unauthorizedHandler?.()
if (shouldToast) {
uni.showToast({ title: '登录已过期,请重新登录', icon: 'none' })
}
}

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

@@ -0,0 +1,20 @@
-- AlterTable
ALTER TABLE `memberships` ADD COLUMN `total_times` INTEGER NULL;
-- AlterTable
ALTER TABLE `orders` ADD COLUMN `membership_id` VARCHAR(191) NULL;
-- CreateIndex
CREATE INDEX `memberships_user_id_card_type_id_idx` ON `memberships`(`user_id`, `card_type_id`);
-- CreateIndex
CREATE INDEX `orders_membership_id_idx` ON `orders`(`membership_id`);
-- AddForeignKey
ALTER TABLE `orders` ADD CONSTRAINT `orders_membership_id_fkey` FOREIGN KEY (`membership_id`) REFERENCES `memberships`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- Backfill purchased-times snapshot for existing times/trial cards
UPDATE `memberships` `m`
INNER JOIN `card_types` `ct` ON `ct`.`id` = `m`.`card_type_id`
SET `m`.`total_times` = `ct`.`total_times`
WHERE `ct`.`total_times` IS NOT NULL AND `m`.`total_times` IS NULL;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `users` ADD COLUMN `last_login_at` DATETIME(3) NULL;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `bookings` ADD COLUMN `membership_times_deducted` BOOLEAN NULL;

View File

@@ -0,0 +1,4 @@
ALTER TABLE `users` ADD COLUMN `invite_code` VARCHAR(6) NULL;
CREATE UNIQUE INDEX `users_invite_code_key` ON `users` (`invite_code`);
ALTER TABLE `orders` ADD COLUMN `invite_inviter_id` VARCHAR(191) NULL, ADD COLUMN `purchased_category` VARCHAR(191) NULL;
INSERT INTO `card_types` (`id`, `name`, `type`, `total_times`, `duration_days`, `price`, `is_active`, `sort_order`, `updated_at`) VALUES ('invite-reward-card', '邀请好友赠课', 'TIMES', 1, 365, 0, false, 9999, NOW());

View File

@@ -0,0 +1,27 @@
-- CreateTable
CREATE TABLE `lesson_supplements` (
`id` VARCHAR(191) NOT NULL,
`user_id` VARCHAR(191) NOT NULL,
`request_id` VARCHAR(80) NOT NULL,
`quantity` INTEGER NOT NULL,
`membership_id` VARCHAR(191) NULL,
`deducted_times` INTEGER NOT NULL DEFAULT 0,
`card_name` VARCHAR(191) NULL,
`remark` VARCHAR(200) NULL,
`operator_id` VARCHAR(191) NOT NULL,
`operator_name` VARCHAR(191) NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`revoked_at` DATETIME(3) NULL,
`revoked_by` VARCHAR(191) NULL,
INDEX `lesson_supplements_user_id_revoked_at_created_at_idx`(`user_id`, `revoked_at`, `created_at`),
UNIQUE INDEX `lesson_supplements_user_id_request_id_key`(`user_id`, `request_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `lesson_supplements` ADD CONSTRAINT `lesson_supplements_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `lesson_supplements` ADD CONSTRAINT `lesson_supplements_membership_id_fkey` FOREIGN KEY (`membership_id`) REFERENCES `memberships`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -38,6 +38,7 @@ enum TimeSlotSource {
}
enum BookingStatus {
PENDING_CONFIRMATION
CONFIRMED
CANCELLED
COMPLETED
@@ -50,26 +51,78 @@ 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())
inviteCode String? @unique @map("invite_code") @db.VarChar(6)
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")
lastLoginAt DateTime? @map("last_login_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
lessonSupplements LessonSupplement[]
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 +132,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 +140,7 @@ model CardType {
memberships Membership[]
orders Order[]
flashSales FlashSale[]
@@map("card_types")
}
@@ -95,17 +150,22 @@ model Membership {
userId String @map("user_id")
cardTypeId String @map("card_type_id")
remainingTimes Int? @map("remaining_times")
totalTimes Int? @map("total_times")
startDate DateTime @map("start_date")
expireDate DateTime @map("expire_date")
status MembershipStatus @default(ACTIVE)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
lessonSupplements LessonSupplement[]
user User @relation(fields: [userId], references: [id])
cardType CardType @relation(fields: [cardTypeId], references: [id])
bookings Booking[]
orders Order[]
inviteRewardGrants InviteRewardGrant[]
@@index([userId])
@@index([userId, cardTypeId])
@@index([status])
@@map("memberships")
}
@@ -148,18 +208,25 @@ model TimeSlot {
}
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()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
id String @id @default(uuid())
userId String @map("user_id")
timeSlotId String @map("time_slot_id")
membershipId String @map("membership_id")
membershipTimesDeducted Boolean? @map("membership_times_deducted")
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,26 +234,90 @@ 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")
cardTypeId String @map("card_type_id")
membershipId String? @map("membership_id")
orderNo String @unique @map("order_no")
inviteInviterId String? @map("invite_inviter_id")
purchasedCategory String? @map("purchased_category")
amount Decimal @db.Decimal(10, 0)
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])
membership Membership? @relation(fields: [membershipId], references: [id])
flashSaleOrder FlashSaleOrder?
inviteReferrals InviteReferral[]
@@index([userId])
@@index([status])
@@index([membershipId])
@@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], onDelete: Restrict)
@@index([inviterId, grantedAt])
@@map("invite_reward_grants")
}
model StudioConfig {
id String @id @default(uuid())
name String
@@ -202,3 +333,72 @@ 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")
}
// Historical totals without fabricated scheduled dates.
model LessonSupplement {
id String @id @default(uuid())
userId String @map("user_id")
requestId String @map("request_id") @db.VarChar(80)
quantity Int
membershipId String? @map("membership_id")
deductedTimes Int @default(0) @map("deducted_times")
cardName String? @map("card_name")
remark String? @db.VarChar(200)
operatorId String @map("operator_id")
operatorName String @map("operator_name")
createdAt DateTime @default(now()) @map("created_at")
revokedAt DateTime? @map("revoked_at")
revokedBy String? @map("revoked_by")
user User @relation(fields: [userId], references: [id])
membership Membership? @relation(fields: [membershipId], references: [id], onDelete: Restrict)
@@unique([userId, requestId])
@@index([userId, revokedAt, createdAt])
@@map("lesson_supplements")
}

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,75 @@
import 'reflect-metadata'
import { BadRequestException } from '@nestjs/common'
import { BookingStatus, UserRole } from '@mp-pilates/shared'
import { PrismaService } from '../../prisma/prisma.service'
import { TeachingAnalyticsService } from '../teaching-analytics.service'
import { AdminController } from '../admin.controller'
import { ROLES_KEY } from '../../auth/roles.decorator'
function booking(id: string, userId: string, slotId: string, date: string, status = BookingStatus.COMPLETED) {
return {
id, userId, status, user: { nickname: '同名学员' },
timeSlot: { id: slotId, date: new Date(`${date}T00:00:00Z`), startTime: '09:00', endTime: '10:30' },
membership: { cardType: { name: '次卡' } },
}
}
describe('TeachingAnalyticsService', () => {
const findMany = jest.fn()
const service = new TeachingAnalyticsService({ booking: { findMany } } as unknown as PrismaService)
beforeEach(() => { jest.useFakeTimers().setSystemTime(new Date('2026-09-09T03:00:00Z')); findMany.mockReset() })
afterEach(() => jest.useRealTimers())
it.each(['2026-13', '2026-00', '2026-9', '', '2026-09-01', '1999-12', undefined])('rejects invalid month %s before querying', async month => {
await expect(service.getMonthly(month as string)).rejects.toBeInstanceOf(BadRequestException)
expect(findMany).not.toHaveBeenCalled()
})
it('counts shared sessions and duration once, students by identity, and excludes other statuses', async () => {
findMany.mockResolvedValue([
booking('previous', 'a', 'old', '2026-08-31'),
booking('1', 'a', 'one', '2026-09-01'), booking('2', 'b', 'one', '2026-09-01'),
booking('3', 'a', 'two', '2026-09-03'),
...[BookingStatus.CANCELLED, BookingStatus.NO_SHOW, BookingStatus.CONFIRMED, BookingStatus.PENDING_CONFIRMATION]
.map((status, index) => booking(`other${index}`, 'c', `other${index}`, '2026-09-04', status)),
])
const result = await service.getMonthly('2026-09')
expect(result.summary).toEqual({ sessions: 2, attendances: 3, students: 2, minutes: 180, teachingDays: 2 })
expect(result.previous.sessions).toBe(1)
expect(result.records).toHaveLength(7)
expect(result.records.every(row => row.date.startsWith('2026-09'))).toBe(true)
expect(findMany).toHaveBeenCalledTimes(1)
})
it.each([
['2026-01', '2025-12-01', '2026-02-01'],
['2024-02', '2024-01-01', '2024-03-01'],
])('uses half-open course date boundaries for %s', async (month, from, to) => {
findMany.mockResolvedValue([])
const result = await service.getMonthly(month)
expect(findMany).toHaveBeenCalledWith(expect.objectContaining({
where: { timeSlot: { date: { gte: new Date(`${from}T00:00:00Z`), lt: new Date(`${to}T00:00:00Z`) } } },
}))
expect(result.previousMonth).toBe(from.slice(0, 7))
expect(result.summary).toEqual({ sessions: 0, attendances: 0, students: 0, minutes: 0, teachingDays: 0 })
})
it('flags only unfinished bookings past their China-time end, without converting status', async () => {
const future = booking('future', 'b', 'future', '2026-09-09', BookingStatus.CONFIRMED)
future.timeSlot.endTime = '11:30'
findMany.mockResolvedValue([
booking('past', 'a', 'past', '2026-09-09', BookingStatus.CONFIRMED), future,
booking('cancel', 'c', 'cancel', '2026-09-09', BookingStatus.CANCELLED),
])
const result = await service.getMonthly('2026-09')
expect(result.records.map(row => row.needsReview)).toEqual([true, false, false])
expect(result.summary.sessions).toBe(0)
expect(result.records[0].status).toBe(BookingStatus.CONFIRMED)
})
it('inherits the admin-only controller role and authentication guards', () => {
expect(Reflect.getMetadata(ROLES_KEY, AdminController)).toEqual([UserRole.ADMIN])
const guards = Reflect.getMetadata('__guards__', AdminController) as Array<{ name: string }>
expect(guards.map(guard => guard.name)).toEqual(['JwtAuthGuard', 'RolesGuard'])
})
})

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