61 Commits

Author SHA1 Message Date
richarjiang
d91282ecbd fix: 修复身体画像约课阻断、页面响应式丢失及多项边界问题
- fix(booking): 修复未认领画像阻断约课问题,支持约课事务内自动认领绑定
- fix(app): 修复 Pinia 状态解构导致响应式失效,补齐 PortraitRadar 组件实例传参
- fix(admin): 修复经营助手复测待办点击 404,优化体验课后线索回访文案
- fix(server): 线下评估日期间隔按中国时区校验,复测按期次单项核销,优化单次评估分享卡与限流清理
- test: 补齐 body-portrait-offline 与 booking 来源画像单元测试

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 22:45:44 +08:00
richarjiang
ab6602e41d feat: 新增个人身体画像评估与馆主经营助手
把 3 分钟身体状态评估做成独立获客链路,匿名测评后登录认领完整报告,并接入体验预约与今日待办。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 22:09:19 +08:00
richarjiang
b8c0dd6781 fix(server): hide CLOSED rows from preview for legacy mini-program compatibility
The previous fix made publishDaySlots CLOSE rows instead of DELETE them.
That works correctly on the server, but the live mini-program still ships
the old schedule.vue which only knows about isPublished (no status
field). It would re-render the CLOSED rows with the '已发布' badge,
making it look like 'clear day' did nothing.

Hide CLOSED rows from getSchedulePreview so the legacy client shows
'当日暂无排课' for rest days — the same UX it had before this fix
shipped. All-CLOSED days return []; days with a mix return only the
non-CLOSED rows; days the cron hasn't touched still return the ghost
template.

The republished mini-program (with status-aware rendering) will see the
same [] for rest days, which is consistent. Tests: 4 new specs covering
all-OPEN / all-CLOSED / mixed / empty-day cases.
2026-09-10 22:36:41 +08:00
richarjiang
806f3ee770 fix(server): preserve admin 'rest day' intent across cron runs
publishDaySlots used to DELETE the TimeSlot rows of a day that an admin
just 'cleared', which made the nightly slot-generation cron resurrect
the default schedule the next morning — a real production bug where
classes would reappear on a teacher's rest day.

Changes:
- publishDaySlots now CLOSEs (never DELETEs) orphaned rows, and materializes
  the default template for days with no rows so an empty publish still
  records intent. update on existing rows preserves their current status,
  so re-publishing the page cannot silently reopen a CLOSED rest day.
- SlotGeneratorService.generateSlots pre-fetches dates that already have any
  TimeSlot row and skips them, so the cron no longer overwrites admin-touched
  days even when admin only set status without changing times.
- SlotGeneratorService.prunePastClosedSlots (new) deletes past CLOSED slots
  with no bookings, scheduled nightly at 02:35, so the 'no DELETE' rule
  above does not leak rows forever.
- schedule.vue surfaces the CLOSED state with a grey '已关闭' badge and
  card style.

Tests: 12 new specs across time-slot.service.spec.ts (publishDaySlots
behaviour) and slot-generator.service.spec.ts (skip touched dates +
prune conditions). Full suite: 315 / 315 passing.
2026-09-10 22:32:32 +08:00
richarjiang
9af558286b chore: 清理管理中心 fetchDashboardStats 与 GET /admin/stats 已无用的接口 2026-09-10 19:23:08 +08:00
richarjiang
99814f3720 refactor(app): 移除管理中心顶部统计卡,数据已在统计分析页提供 2026-09-10 19:22:44 +08:00
richarjiang
014685ed04 refactor(app): 管理中心列表改为纯文字入口并按业务重组分组 2026-09-10 19:22:18 +08:00
richarjiang
9f5608f13c fix(app): 教学报告两张卡片合并为同一列表组,去掉四周圆角 2026-09-10 19:21:18 +08:00
richarjiang
3e049d2c1d feat: 优化微信订阅消息体系并新增约课取消通知与开课前1小时提醒
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 16:20:13 +08:00
richarjiang
d32f592e54 fix(app): 修复预约页首次进入定位当前时段不生效
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 15:33:37 +08:00
richarjiang
d793749134 fix(app): 在确认手势内同步调起订阅,预约页首次进入定位到当前时段
微信要求授权框落在 tap 同步栈,先 await 会丢失弹层;订阅失败不再打断预约或支付。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 14:53:14 +08:00
richarjiang
57edd8dcc0 perf: 移除秒杀相关功能 2026-09-10 11:33:19 +08:00
richarjiang
51dea488f6 feat: 优化预定页面UI和交互 2026-09-10 11:00:13 +08:00
richarjiang
22407a7ff9 fix(booking): 允许管理员/老师通过同一接口取消任意预约
将 cancelBooking 改为接收 actor({ id, isAdmin }),服务端按 JWT role
分流:普通成员仅能取消自己的预约,管理员跳过 owner check。
history 备注自动按 actor 选择「学员/管理员」前缀,审计可追溯。

客户端无感,仍调 PUT /booking/:id/cancel,前端零改动。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 19:50:41 +08:00
richarjiang
d941f1b6a9 perf: 优化进度条 2026-09-09 19:19:18 +08:00
richarjiang
6fab1155c7 fix: 防止成长档案编辑弹窗被误关
微信端点击输入会冒泡到遮罩,且 onShow 刷新会清空编辑态,导致体测、笔记和照片无法填写。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 16:02:24 +08:00
richarjiang
139882d7a1 feat: 支持课后评价与成长档案
完成后即可评价并订阅提醒,馆主可记录体测、笔记与成长照片,首页展示匿名星级均分。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 15:55:48 +08:00
richarjiang
c3e46f7ffa fix(teaching-schedule): 当日课表展示所有状态,并支持点入课程详情
- 后端 getTeachingScheduleByDate 在查询当天时不再以 PENDING/CONFIRMED 过滤 timeSlot
  也不限制 include.bookings 的状态,但仍保留 EXISTS 守卫过滤空 slot
- 其他日期维持原「只看待上课」语义
- 抽出私有 formatLocalDate 工具方法,消除服务端 YYYY-MM-DD 构造的 3 处重复
- 前端 teaching-schedule 的 session 卡片可点击跳到 /pages/booking/detail?slotId=
- 补全 student__status--completed / --cancelled 样式
2026-09-09 14:51:06 +08:00
richarjiang
75ef5e94a6 refactor: 调整 admin 目录结构并精简个人中心快捷区
- 将 admin 相关的 store/utils 移入对应子目录(pages/admin/stores、pages/admin/utils)
- 更新 manifest.json、pages.json 路由与配置
- 个人中心 ProfileMenu 移除顶部「我的会员卡 / 我的预约」快捷卡片,与 UserCard 会员卡入口合并
- 「我的预约」下移至菜单列表,紧邻「个人信息」
- 清理 profile/index.vue 中不再使用的 bookingStore / 计算属性 / 透传
2026-09-09 14:36:34 +08:00
richarjiang
57107c02dc perf: 优化个人中心会员卡进度展示 2026-09-09 11:27:03 +08:00
richarjiang
726c65f0f0 perf: 会员卡页面支持分享 2026-09-09 10:28:11 +08:00
richarjiang
6e8fc45138 fix: 修复默认启动入口 2026-09-09 10:26:16 +08:00
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
217 changed files with 25546 additions and 8509 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`

15
.deploy-tmp/check-fk.js Normal file
View File

@@ -0,0 +1,15 @@
// One-off diagnostic — list FK constraints on orders.flash_sale_id
// Run from packages/server so .env is loaded.
const { PrismaClient } = require('@prisma/client')
const p = new PrismaClient()
;(async () => {
const rows = await p.$queryRawUnsafe(`
SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN ('orders', 'flash_sales', 'flash_sale_orders')
ORDER BY TABLE_NAME, ORDINAL_POSITION
`)
console.log('FK rows:', JSON.stringify(rows, null, 2))
await p.$disconnect()
})().catch((e) => { console.error(e); process.exit(1) })

View File

@@ -0,0 +1,33 @@
// Mark the failed drop_flash_sale migration as rolled_back so deploy can retry.
const { PrismaClient } = require('@prisma/client')
const p = new PrismaClient()
const dump = (x) => JSON.stringify(x, (_, v) => typeof v === 'bigint' ? v.toString() : v, 2)
;(async () => {
const before = await p.$queryRawUnsafe(
`SELECT migration_name, finished_at, rolled_back_at, applied_steps_count
FROM _prisma_migrations
WHERE migration_name = ?`,
'20260910030338_drop_flash_sale'
)
console.log('before:', dump(before))
const result = await p.$executeRawUnsafe(
`UPDATE _prisma_migrations
SET rolled_back_at = NOW()
WHERE migration_name = ?
AND finished_at IS NULL
AND rolled_back_at IS NULL`,
'20260910030338_drop_flash_sale'
)
console.log('rows updated:', result)
const after = await p.$queryRawUnsafe(
`SELECT migration_name, finished_at, rolled_back_at, applied_steps_count
FROM _prisma_migrations
WHERE migration_name = ?`,
'20260910030338_drop_flash_sale'
)
console.log('after:', dump(after))
await p.$disconnect()
})().catch((e) => { console.error(e); process.exit(1) })

View File

@@ -0,0 +1,27 @@
// Verify migration result on production
const { PrismaClient } = require('@prisma/client')
const p = new PrismaClient()
;(async () => {
const tables = await p.$queryRawUnsafe(`
SELECT TABLE_NAME
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME LIKE '%flash%'
`)
const ordersCol = await p.$queryRawUnsafe(`
SELECT COLUMN_NAME
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'orders'
AND COLUMN_NAME = 'flash_sale_id'
`)
const mig = await p.$queryRawUnsafe(`
SELECT migration_name, finished_at, rolled_back_at
FROM _prisma_migrations
WHERE migration_name = '20260910030338_drop_flash_sale'
`)
console.log('flash tables:', tables)
console.log('orders.flash_sale_id col:', ordersCol)
console.log('migration row:', mig)
await p.$disconnect()
})().catch((e) => { console.error(e); process.exit(1) })

37
AGENTS.md Normal file
View File

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

View File

@@ -71,12 +71,12 @@ pnpm deploy:server # 部署后端到生产环境
### 卡类型枚举 ### 卡类型枚举
- `CardTypeCategory` (TIMES/DURATION/TRIAL) 定义在 `packages/shared/src/enums.ts` - `CardTypeCategory` (TIMES/DURATION/TRIAL) 定义在 `packages/shared/src/enums.ts`
- 会员管理筛选使用特殊值 `NONE` 表示无卡/无有效会员(不在枚举中) - 会员管理筛选使用特殊值 `ACTIVE` 表示持有 ACTIVE 状态会员卡的会员用户(页面默认),`NONE` 表示无卡/无有效会员(两者不在卡种枚举中)
- 前端选项硬编码在 `src/pages/admin/members.vue``cardTypeOptions`,需与枚举保持同步 - 前端选项硬编码在 `src/pages/admin/members.vue``cardTypeOptions`,需与枚举保持同步
### 管理后台 API 模式 ### 管理后台 API 模式
- `/admin/members` 支持 `page`, `limit`, `search`, `cardType` 参数 - `/admin/members` 支持 `page`, `limit`, `search`, `cardType` 参数
- `cardType=NONE` → 无有效会员的用户;其他值对应 `CardTypeCategory` - `cardType=ACTIVE` → 持有任意 ACTIVE 状态会员卡;`cardType=NONE` → 无 ACTIVE 状态会员卡;省略参数查看全部用户;其他值对应 `CardTypeCategory`
- 预约统计total/completed/cancelled通过 `groupBy` 批量查询 - 预约统计total/completed/cancelled通过 `groupBy` 批量查询
### 筛选组件模式 ### 筛选组件模式
@@ -85,4 +85,38 @@ pnpm deploy:server # 部署后端到生产环境
### Admin Store (`src/stores/admin.ts`) ### Admin Store (`src/stores/admin.ts`)
- 聚合所有管理端 API 调用weekTemplates、cardTypes、studioConfig、members、bookings、orders、stats 等 - 聚合所有管理端 API 调用weekTemplates、cardTypes、studioConfig、members、bookings、orders、stats 等
- 遵循不可变更新原则:`data` 赋值使用展开运算符 `[...newData]` - 遵循不可变更新原则:`data` 赋值使用展开运算符 `[...newData]`
### 历史课程补录
- `LessonSupplement` 独立记录历史累计课时,归属 user 模块DTO 放 `user/dto`,测试放 `user/__tests__`,前端入口为 `pages/admin/member-supplement.vue`
- 补录不创建预约或时段;只增加累计已完成节数,不推测上课日期、天数、时长,不参与月度统计、活跃网格或邀请奖励。
- 可选择从本人有限次会员卡扣次;补录与扣次必须事务提交,保存实际扣次快照,撤销只返还实际扣次。请求标识用于幂等重试。
- Prisma 迁移按 `YYYYMMDDHHmmss_description/migration.sql` 存放;补录表采用增量迁移,回退说明维护在 `docs/lesson-supplement.md`,不删除审计记录。
### 月度教学统计
- 统计归属 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。
### 个人中心会员卡包
- 个人中心资料区域以单行展示持有卡种和张数,下方每张卡以单行浅色进度槽承载卡名和用量文字,不使用大卡片或轮播;点击进入我的会员卡。`OwnedMembershipCard.vue` 在我的会员卡页面展示余额、已用进度和到期日。
- 累计上课、本月上课、剩余课时集中在我的会员卡页面,个人资料卡不重复展示汇总。
- 次数限制以 remainingTimes 是否为 null 判断,不能按卡种推断。次数进度表示已用占总次数,已用包括预约占用;不限次卡不伪造耗课数。
- 会员卡加载失败显示重试,不当作无卡;会话变化时丢弃旧请求结果。
### 课后评价与成长档案
- 评价归属 booking 模块,档案归属 user 模块;共享契约放 shared/src/types/member-care.ts页面沿用 booking/profile/admin 目录,不新增顶层业务目录。
- COMPLETED 后立即可评价,无 24 小时截止;完成后 24 小时仅提醒未评价预约。唯一 bookingId 防重复,提醒状态保存在 Booking 上,定时任务原子领取,未知发送结果不自动重发。
- 星级均分与 NPS 分开NPS 仅使用可选 010 推荐意愿910 推荐者、06 贬损者),按中国自然月聚合并展示样本数。
- 教练私密笔记必须在服务端过滤;课程批注必须属于该学员的已完成预约。体测允许缺项,不以缺项当 0累计课时包含有效补录里程碑为 10/30/50 节。
- 照片仅用于学员与馆主之间的档案展示,不能用于公开宣传。学员本人按照片授权/撤回,馆主不能代授权。与馆图共用 COS 桶,对象前缀 `progress/`,上传为私有 ACL读取用短时签名禁止落库公共链接上传凭证绑定学员及照片记录。
- 新增迁移目录只放 migration.sql测试沿用各模块 __tests__;部署配置与验收清单放 docs/member-care.md。
- 成长档案的两端共用 `components/MemberProgress.vue`,仅此跨端组件直接请求 progress API避免主包引用 admin 分包 Store馆主评价页面仍通过 admin Store 访问。
### 个人身体画像
- 线上获客归属独立 `body-portrait` 模块,不是成长档案的一个页面。共享契约在 `packages/shared/src/types/body-portrait.ts`,规格在 `docs/body-portrait.md`
- 评分只在服务端规则引擎计算;匿名测评用访问令牌哈希,不用可枚举 ID 做权限。
- 安全分流不计分。完整报告需登录认领,体验预约需手机号。现有 TRIAL 体验卡承接转化。
- 馆主「今日经营助手」只展示待办,不做成通用 CRM。主包不得引用 admin store。

View File

@@ -117,6 +117,7 @@ echo_info "文件上传完成"
# 6. 服务器端安装依赖并重启 # 6. 服务器端安装依赖并重启
echo_info "服务器部署操作..." echo_info "服务器部署操作..."
ssh ${SERVER_USER}@${SERVER_HOST} "bash -l" << 'ENDSSH' ssh ${SERVER_USER}@${SERVER_HOST} "bash -l" << 'ENDSSH'
set -e
export PATH="$HOME/.nvm/versions/node/v22.17.1/bin:$PATH" export PATH="$HOME/.nvm/versions/node/v22.17.1/bin:$PATH"
SERVER_PATH="/usr/local/web/mp-pilates-server" SERVER_PATH="/usr/local/web/mp-pilates-server"
PM2_APP_NAME="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`
这样后面扩展、迁移、审计都会更稳。

118
docs/body-portrait.md Normal file
View File

@@ -0,0 +1,118 @@
# 个人身体画像增长系统
入口小程序「3 分钟身体状态评估」、报告页、体验预约,以及管理中心「今日经营助手」。线上获客归属独立 `body-portrait` 模块;线下专业评估与改善计划复用成长档案口径,不把问卷结果写成医疗诊断。
## 产品口径
- 对外名称:**3 分钟身体状态评估 / 个人身体画像**。只使用「关注度、可能表现、建议进一步评估」。禁止「诊断、疾病检测、患病风险、准确率」。
- 分数 0100 只表示**问卷关注度**,不表示身体能力、疼痛程度或患病概率。
- 陌生用户可匿名完成问卷。完成后先看摘要;微信登录并认领后看完整报告。点击体验预约时必须授权手机号。
- 安全分流不计分。命中持续明显疼痛、近期受伤、手脚明显麻木、医生限制运动或医疗恢复期时,不展示动作建议和强销售 CTA。
- 评分只在服务端用版本化规则引擎计算。客户端不得提交分数、画像类型或线索阶段。LLM 不决定结论。
- 现有 `TRIAL` 体验卡就是「评估 + 普拉提体验」。画像只补来源关联,不新增商品类型。
- 成长照片沿用私有 COS、短时签名和学员授权。馆主可见不等于可公开分享。
## 免责声明
各入口固定文案:
> 本评估不是医疗诊断或体态疾病检测,仅用于运动训练参考。若你正处于明显疼痛、伤病或医生限制运动的阶段,请先由医生或相应专业人士确认运动条件。
保存说明:匿名未认领草稿默认 30 天后清理。认领后的健康相关数据仅用于训练评估与跟进;学员可申请删除,审计事件保留必要边界。
## 问卷版本 `portrait-q-v1`
每屏一个决策,可多选的题目明确标注。不询问「骨盆前倾」等专业病名。
| 步骤 | ID | 题干 | 类型 |
| --- | --- | --- | --- |
| 1 | `concerns` | 最近身体哪里最困扰你? | 人体图多选 |
| 2 | `goal` | 如果训练有效果,你最希望看到什么变化? | 单选 |
| 3 | `sittingHours` | 每天坐多久? | 单选 |
| 4 | `exerciseFreq` | 平时运动频率? | 单选 |
| 5 | `workPosture` | 工作时最常见状态? | 单选 |
| 6 | `endOfDayFatigue` | 一天结束后,身体哪里最累? | 人体图多选 |
| 7 | `afterSitting` | 长时间坐着后,你会出现? | 多选 |
| 8 | `standingNotice` | 自然站立时,你有没有注意过? | 多选 |
| 9 | `safety` | 最近是否存在以下情况? | 多选,不计分 |
部位:头颈 `neck`、肩 `shoulder`、上背 `upperBack`、腰 `lowBack`、骨盆 `pelvis`、髋 `hip`、膝 `knee`、腿 `leg`
目标:看起来更挺拔 `posture`、肩颈没那么累 `neckRelief`、久坐更舒服 `backComfort`、腹部更有力量 `core`、身体线条更好 `shaping`、腿型/髋部状态改善 `hipLeg`、动作更稳定 `stability`、我有自己的目标 `other`
## 五维与关注度
维度:肩颈状态 `cervicalShoulder`、脊柱活动 `spinalMobility`、核心控制 `coreControl`、髋骨盆状态 `hipPelvis`、下肢稳定 `lowerLimb`
| 分数 | 关注度 |
| ---: | --- |
| 030 | 低关注 |
| 3150 | 轻度关注 |
| 5170 | 中度关注 |
| 7185 | 高关注 |
| 86100 | 重点关注 |
规则版本 `portrait-r-v1`:先按答案累加,再把每一维 clamp 到 0100。安全选项不加分。
## 画像模板8 种)
主类型 1 个,次类型最多 1 个。
| ID | 标签 |
| --- | --- |
| `sedentaryNeckTension` | 久坐肩颈紧张型 |
| `sedentaryCoreWeak` | 久坐核心弱化型 |
| `neckCompensation` | 肩颈代偿型 |
| `hipTight` | 髋部紧张型 |
| `upperLowerCross` | 上下交叉混合型 |
| `lowerLimbUnstable` | 下肢稳定不足型 |
| `mixedFatigue` | 全身疲劳累积型 |
| `lowOverallConcern` | 整体关注度较低型 |
## 报告结构
1. 一句话结论 + 主/次类型 + 问卷依据(充分/一般,不是准确率)
2. 最多 3 个优先关注点,必须引用用户原话/选项
3. 问题之间的关联(例如久坐 → 胸椎活动减少 → 肩颈承担更多)
4. 三条日常建议;安全分流不下发动作。动作建议含停止条件
5. 线上局限 → 到店评估项目 → 现有体验卡
匹配度只展示「问卷依据:充分 / 一般」,由有效回答数和证据条数计算。
## 漏斗事件
服务端事实:`assessment_started` `assessment_completed` `assessment_claimed` `phone_bound` `trial_purchased` `trial_booked` `trial_attended` `plan_purchased`
客户端可报但需幂等:`report_viewed` `advice_viewed` `trial_clicked` `report_shared`
`trial_attended` 只由体验卡预约被核销COMPLETED产生不等于自动完成也不含补录。
渠道:`xhs` `wechat` `dianping` `member_share` `organic` `other`。分享链接只用邀请码/活动码,不暴露内部用户 ID。
## 线索阶段
只允许前进:`VISIT``STARTED``COMPLETED``CLAIMED``PHONE_BOUND``TRIAL_PURCHASED``TRIAL_BOOKED``TRIAL_ATTENDED``PLAN_PURCHASED``TRAINING``RENEWAL_DUE``RENEWED`。馆主可记跟进备注,不能把阶段任意倒退覆盖历史。
## 线下评估与改善计划V2
到店 60 分钟:沟通目标 → 静态体态 → 活动度 → 动作控制 → 普拉提体验 → 解释结果。协议版本 `offline-v1`。operatorId 不是授课老师。
12 周计划三阶段14 重新建立控制58 改善活动能力912 整合身体动作。卖的是改善计划不是「20 节课」。
## 复测与分享V3
默认可在第 4 / 8 / 12 节生成复测待办。对比必须同协议、同观察项。成长卡片默认不含照片;公开导出需学员单次确认。分享落地进入 `source=member_share` 的新测评。
## 部署
新增表见 `packages/server/prisma/migrations/20260911180000_body_portrait/migration.sql`。发布顺序:`prisma migrate deploy` → 后端 → 小程序。回退先回退应用,表可保留。
## 验收清单
- 匿名可完成、可续填;登录后同一会话只能认领一次。
- 完整报告需登录;体验预约需手机号。
- 安全分流不计分、无动作建议、无强销售。
- 分数与画像只来自服务端快照。
- 馆主今日待办能看到「测完未约 / 体验后未购 / 待复测」。
- 漏斗按渠道聚合;补录不进入获客转化。
- 照片与分享遵守成长档案隐私规则。

View File

@@ -0,0 +1,41 @@
# 移除秒杀功能
按业务调整下线限时秒杀活动。前后端代码、共享类型、Prisma schema 与迁移均已同步清理。
## 清理范围
| 层 | 内容 |
| --- | --- |
| 后端模块 | `packages/server/src/flash-sale/`(service、controller、admin controller、dto、测试) |
| 调度任务 | `SchedulerService.handleExpireFlashSaleReservations` 及其依赖注入 |
| 支付流程 | `PaymentService``flashSaleOrder` 标记为 PAID 的分支 |
| 前端页面 | `pages/flash-sale/detail.vue``pages/admin/flash-sales.vue` |
| 前端组件 | `components/FlashSaleSection.vue` |
| 前端状态 | `stores/flash-sale.ts` |
| 前端入口 | `pages/admin/index.vue` 秒杀管理菜单与对应样式 |
| 首页 | `pages/home/index.vue``FlashSaleSection` 引用 |
| 共享类型 | `shared/src/types/flash-sale.ts`,`enums.ts``FlashSaleStatus``FlashSaleOrderStatus`,以及 `index.ts` 的 re-export |
| 数据库 | Prisma 删除 `FlashSale``FlashSaleOrder` 模型、相关枚举与 `Order.flash_sale_id` 字段 |
## 数据库迁移
新增 `packages/server/prisma/migrations/20260910030338_drop_flash_sale/migration.sql`
执行 `pnpm prisma migrate deploy` 后以下内容会被移除:
- `flash_sales``flash_sale_orders` 两张表
- `orders.flash_sale_id` 列与外键
- `FlashSaleStatus``FlashSaleOrderStatus` 枚举
迁移以 MySQL 方言编写;SQLite 开发环境由 Prisma shadow database 自动重建。
## 验证
```bash
pnpm build:shared
pnpm build:server
pnpm test # 包含 payment / scheduler 套件
```
## 回退说明
回退需要还原 schema 与源码,本项目不保留 git 自动恢复外的额外兜底。如需重新启用秒杀,按 git 历史恢复即可,并运行 `pnpm prisma migrate reset` 重新初始化数据库。

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`,使依赖安装或迁移失败时停止发布。

41
docs/member-care.md Normal file
View File

@@ -0,0 +1,41 @@
# 课后评价与成长档案
入口:预约详情评价、个人中心「我的成长档案」、管理中心「课后评价」与会员档案「成长档案」。评价归属 booking 模块,档案归属 user 模块。共享契约在 `packages/shared/src/types/member-care.ts`
## 产品口径
- 课程变为 COMPLETED 后即可评价,没有 24 小时截止。完成后 24 小时只提醒仍未评价的预约。
- 同一预约只能评价一次。提醒状态写在 Booking 上,定时任务原子领取;发送结果未知或失败不自动重发。
- 星级均分与 NPS 分开。NPS 只用可选的 010 推荐意愿910 推荐者、06 贬损者),按中国自然月聚合并展示样本数。
- 教练私密笔记只在服务端过滤。课程批注必须属于该学员的已完成预约。
- 体测允许缺项,缺项不按 0 计算。累计课时含有效补录,里程碑为 10 / 30 / 50 节。
- 成长照片仅用于学员与馆主之间的档案,不用于公开宣传。学员本人授权或撤回,馆主不能代授权。误传或学员主动删除会同时删除数据库记录和 COS 对象。
## 部署配置
新增环境变量(见 `packages/server/.env.example`
| 变量 | 作用 |
| --- | --- |
| `WX_SUBSCRIBE_TEMPLATE_CLASS_REVIEW` | 课后评价订阅消息模板 ID |
成长照片与馆图共用 `COS_BUCKET``COS_SECRET_ID``COS_SECRET_KEY``COS_REGION`。对象写在 `progress/{userId}/` 下,上传带私有 ACL读取用约 60 秒签名 URL不落库公共链接。小程序合法域名沿用现有 COS 域名即可。
评价提醒模板字段由服务端按预约填充:`thing1` 课程名称(工作室名)、`thing2` 课程教练Iris`time3` 课程时间、`thing4` 温馨提示。未配置模板 ID 时定时任务不领取预约。
回退:先回退应用代码。新增表可保留;`progress/` 下对象不会被旧代码读取。不要在生产直接 `DROP TABLE`
## 迁移
目录 `packages/server/prisma/migrations/20260909120000_member_care/migration.sql`。发布时先 `prisma migrate deploy`,再发布后端,最后发布小程序。
## 验收清单
- 学员在 CONFIRMED 或 COMPLETED 且未评价时都可以订阅提醒;核销后仍能订阅。
- 完成后可立即评价;重复提交返回已评价。
- 首页匿名均分不含评论文案管理端趋势按中国自然月NPS 与星级分开展示。
- 私密笔记学员不可见;馆主在学员授权前不能读取照片,授权后可看,撤回后不能再签发。
- 误传照片可删除,删除后档案和 COS 对象都不再保留。
- 体测可只填一项,柔韧度允许负值;累计课时含未撤销补录。
- 未配置评价模板时,定时任务不领取预约。
- 小程序真机确认可上传、可用签名链接预览成长照片。

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

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

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

@@ -0,0 +1,45 @@
<template>
<view class="sil">
<text class="hint">点选身体区域可多选</text>
<view class="figure">
<view class="head zone" :class="{ on: selected.includes(BodyRegion.NECK) }" @tap="toggle(BodyRegion.NECK)">头颈</view>
<view class="row">
<view class="shoulder zone" :class="{ on: selected.includes(BodyRegion.SHOULDER) }" @tap="toggle(BodyRegion.SHOULDER)"></view>
</view>
<view class="torso zone" :class="{ on: selected.includes(BodyRegion.UPPER_BACK) }" @tap="toggle(BodyRegion.UPPER_BACK)">上背</view>
<view class="waist zone" :class="{ on: selected.includes(BodyRegion.LOW_BACK) }" @tap="toggle(BodyRegion.LOW_BACK)"></view>
<view class="pelvis zone" :class="{ on: selected.includes(BodyRegion.PELVIS) }" @tap="toggle(BodyRegion.PELVIS)">骨盆</view>
<view class="row">
<view class="hip zone" :class="{ on: selected.includes(BodyRegion.HIP) }" @tap="toggle(BodyRegion.HIP)"></view>
</view>
<view class="row">
<view class="knee zone" :class="{ on: selected.includes(BodyRegion.KNEE) }" @tap="toggle(BodyRegion.KNEE)"></view>
</view>
<view class="leg zone" :class="{ on: selected.includes(BodyRegion.LEG) }" @tap="toggle(BodyRegion.LEG)"></view>
</view>
</view>
</template>
<script setup lang="ts">
import { BodyRegion } from '@mp-pilates/shared'
const props = defineProps<{ selected: readonly string[] }>()
const emit = defineEmits<{ (e: 'change', value: BodyRegion[]): void }>()
function toggle(region: BodyRegion) {
const current = new Set(props.selected)
if (current.has(region)) current.delete(region)
else current.add(region)
emit('change', [...current] as BodyRegion[])
}
</script>
<style lang="scss" scoped>
.sil { padding: 12rpx 0 8rpx; }
.hint { display: block; text-align: center; color: #8a7b6e; font-size: 22rpx; margin-bottom: 16rpx; }
.figure { display: flex; flex-direction: column; align-items: center; gap: 10rpx; }
.zone { min-width: 160rpx; padding: 16rpx 28rpx; border-radius: 999rpx; background: #f3eee8; color: #6b5c50; font-size: 24rpx; text-align: center; }
.zone.on { background: #6b8276; color: #fff; }
.head { min-width: 120rpx; }
.row { display: flex; gap: 12rpx; }
</style>

View File

@@ -36,15 +36,12 @@
<!-- Membership card selection --> <!-- Membership card selection -->
<view class="card-section"> <view class="card-section">
<view class="section-label-row"> <view class="section-label-row">
<text class="section-label">选择扣课会员卡</text> <text class="section-label">选择会员卡</text>
</view> </view>
<!-- Single membership --> <!-- Single membership -->
<view v-if="memberships.length === 1" class="card-single"> <view v-if="memberships.length === 1" class="card-single">
<view class="card-item selected"> <view class="card-item selected">
<view class="card-icon-wrap">
<text class="card-icon">💳</text>
</view>
<view class="card-info"> <view class="card-info">
<text class="card-name">{{ memberships[0].cardType.name }}</text> <text class="card-name">{{ memberships[0].cardType.name }}</text>
<text class="card-remain" v-if="memberships[0].remainingTimes !== null"> <text class="card-remain" v-if="memberships[0].remainingTimes !== null">
@@ -61,7 +58,7 @@
</view> </view>
<!-- Multiple memberships picker --> <!-- 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 <view
v-for="m in memberships" v-for="m in memberships"
:key="m.id" :key="m.id"
@@ -69,9 +66,6 @@
:class="{ selected: selectedMembershipId === m.id }" :class="{ selected: selectedMembershipId === m.id }"
@tap="selectedMembershipId = m.id" @tap="selectedMembershipId = m.id"
> >
<view class="card-icon-wrap">
<text class="card-icon">💳</text>
</view>
<view class="card-info"> <view class="card-info">
<text class="card-name">{{ m.cardType.name }}</text> <text class="card-name">{{ m.cardType.name }}</text>
<text class="card-remain" v-if="m.remainingTimes !== null"> <text class="card-remain" v-if="m.remainingTimes !== null">
@@ -85,7 +79,7 @@
<text class="check-icon"></text> <text class="check-icon"></text>
</view> </view>
</view> </view>
</view> </scroll-view>
<!-- No memberships fallback (should not normally appear) --> <!-- No memberships fallback (should not normally appear) -->
<view v-else class="no-card-tip"> <view v-else class="no-card-tip">
@@ -96,22 +90,31 @@
<!-- Deduction tip --> <!-- Deduction tip -->
<view class="deduction-tip" v-if="selectedMembership"> <view class="deduction-tip" v-if="selectedMembership">
<text class="deduction-text"> <text class="deduction-text">
确认后将从{{ selectedMembership.cardType.name }}扣除 1 次课时 {{ selectedMembership.remainingTimes === null
? `${selectedMembership.cardType.name}」有效期内不限次`
: `确认后将从「${selectedMembership.cardType.name}」扣除 1 次课时` }}
</text> </text>
</view> </view>
<!-- Subscribe note -->
<view class="subscribe-tip">
<text class="subscribe-tip-text">🔔 确认预约将同步订阅约课结果课前1小时提醒与取消通知</text>
</view>
<!-- Action buttons --> <!-- Action buttons -->
<view class="action-row"> <view class="action-row">
<view class="btn-outline" @tap="handleCancel"> <view class="btn-outline" @tap="handleCancel">
<text class="btn-outline-text">取消</text> <text class="btn-outline-text">取消</text>
</view> </view>
<view <button
class="btn-confirm" class="btn-confirm"
:class="{ disabled: !selectedMembershipId }" :class="{ disabled: !selectedMembershipId }"
:disabled="!selectedMembershipId || requestingSubscribe"
:loading="requestingSubscribe"
@tap="handleConfirm" @tap="handleConfirm"
> >
<text class="btn-confirm-text">确认预约</text> <text class="btn-confirm-text">确认预约</text>
</view> </button>
</view> </view>
</view> </view>
</view> </view>
@@ -120,6 +123,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pilates/shared' import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pilates/shared'
import { requestBookingCreatedSubscriptionMessage } from '../utils/wechat-subscription'
const props = defineProps<{ const props = defineProps<{
visible: boolean visible: boolean
@@ -134,13 +138,14 @@ const emit = defineEmits<{
}>() }>()
const selectedMembershipId = ref<string>('') const selectedMembershipId = ref<string>('')
const requestingSubscribe = ref(false)
// Auto-select the first membership when popup opens or memberships list changes // Auto-select the first membership when popup opens or memberships list changes
watch( watch(
[() => props.visible, () => props.memberships], [() => props.visible, () => props.memberships],
([visible, memberships]) => { ([visible, memberships]) => {
if (visible && memberships.length > 0) { if (visible) {
selectedMembershipId.value = memberships[0].id selectedMembershipId.value = memberships[0]?.id ?? ''
} }
}, },
{ immediate: true }, { immediate: true },
@@ -150,8 +155,20 @@ const selectedMembership = computed(() =>
props.memberships.find((m) => m.id === selectedMembershipId.value) ?? null, props.memberships.find((m) => m.id === selectedMembershipId.value) ?? null,
) )
function handleConfirm() { async function handleConfirm() {
if (!props.timeSlot || !selectedMembershipId.value) return if (!props.timeSlot || !selectedMembershipId.value) return
if (requestingSubscribe.value) return
requestingSubscribe.value = true
try {
await requestBookingCreatedSubscriptionMessage()
} catch (err: unknown) {
console.warn('[subscribe] booking confirm failed', err)
} finally {
requestingSubscribe.value = false
}
emit('confirm', { emit('confirm', {
timeSlotId: props.timeSlot.id, timeSlotId: props.timeSlot.id,
membershipId: selectedMembershipId.value, membershipId: selectedMembershipId.value,
@@ -169,261 +186,47 @@ function handleMaskTap() {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.popup-mask { .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; }
position: fixed; .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)); }
top: 0; .popup-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24rpx; }
left: 0; .popup-title { font-size: 32rpx; font-weight: 500; color: #514943; }
right: 0; .close-btn { width: 60rpx; height: 60rpx; display: flex; align-items: center; justify-content: center; }
bottom: 0; .close-icon { font-size: 26rpx; color: #8b817b; }
background: rgba(0, 0, 0, 0.45); .info-section { padding: 24rpx; background: #f1e8e1; border-radius: 24rpx; display: flex; flex-direction: column; gap: 14rpx; }
z-index: 1000; .info-row { display: flex; align-items: center; gap: 20rpx; }
display: flex; .info-label { font-size: 24rpx; color: #8b7b70; width: 64rpx; flex-shrink: 0; }
align-items: flex-end; .info-value { font-size: 26rpx; color: #6f5c50; font-weight: 400; }
justify-content: center; .divider { height: 24rpx; }
} .card-section { display: flex; flex-direction: column; gap: 16rpx; }
.section-label { font-size: 26rpx; color: #514943; font-weight: 500; }
.popup-panel { .card-picker-wrap { height: 250rpx; }
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: $primary-selected-bg;
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: $primary-border;
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;
}
.card-item { .card-item {
display: flex; display: flex; align-items: center; padding: 22rpx 24rpx; border-radius: 24rpx;
flex-direction: row; border: 2rpx solid #eee8e3; background: #fff; gap: 20rpx; box-sizing: border-box;
align-items: center; &.selected { border-color: #a5b8ab; background: #f0f4ee; }
padding: 24rpx 20rpx;
border-radius: 16rpx;
border: 2rpx solid $primary-border;
background: $primary-bg;
gap: 20rpx;
transition: border-color 0.15s, background 0.15s;
&.selected {
border-color: $primary-dark;
background: $primary-selected-bg;
}
} }
.card-picker-wrap .card-item { margin-bottom: 12rpx; }
.card-icon-wrap { .card-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
width: 60rpx; .card-name { font-size: 26rpx; font-weight: 400; color: #514943; overflow-wrap: anywhere; }
height: 60rpx; .card-remain { font-size: 22rpx; color: #8b817b; }
border-radius: 14rpx; .check-mark { width: 36rpx; height: 36rpx; border-radius: 50%; background: #6b8276; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
background: linear-gradient(135deg, $primary-color, $primary-dark); .check-icon { font-size: 22rpx; color: #fff; }
display: flex; .no-card-tip { padding: 24rpx; text-align: center; }
align-items: center; .no-card-text { font-size: 24rpx; color: #8b817b; }
justify-content: center; .deduction-tip { padding: 18rpx 4rpx; }
flex-shrink: 0; .deduction-text { font-size: 22rpx; color: #8b817b; line-height: 1.6; }
} .subscribe-tip { padding: 0 4rpx 14rpx; text-align: center; }
.subscribe-tip-text { font-size: 21rpx; color: #7f8a7e; }
.card-icon { .action-row { display: flex; gap: 20rpx; margin-top: 12rpx; }
font-size: 32rpx; .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; }
.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: $primary-dark;
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: $primary-selected-bg;
border-radius: 12rpx;
padding: 16rpx 20rpx;
margin-bottom: 28rpx;
}
.deduction-text {
font-size: 24rpx;
color: $primary-dark;
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 $primary-border;
display: flex;
align-items: center;
justify-content: center;
&:active {
background: #f5f5f5;
}
}
.btn-outline-text {
font-size: 30rpx;
color: #666;
font-weight: 500;
}
.btn-confirm { .btn-confirm {
flex: 2; flex: 2; height: 88rpx; padding: 0; margin: 0; border: none; line-height: 1;
height: 88rpx; border-radius: 999rpx; background: #6b8276;
border-radius: 44rpx; display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, $primary-color, $primary-dark); &::after { border: none; }
display: flex; &:active { background: #597264; }
align-items: center; &.disabled { background: #d9dfd8; }
justify-content: center;
&:active {
opacity: 0.85;
}
&.disabled {
background: $primary-border;
}
}
.btn-confirm-text {
font-size: 30rpx;
color: #fff;
font-weight: 600;
letter-spacing: 1rpx;
.disabled & {
color: #bbb;
}
} }
.btn-confirm-text { font-size: 28rpx; color: #fff; font-weight: 500; .disabled & { color: #677467; } }
</style> </style>

View File

@@ -14,28 +14,36 @@
<!-- Circular logo --> <!-- Circular logo -->
<view class="logo-circle"> <view class="logo-circle">
<image <image
v-if="studioInfo?.logo" v-if="logoImage"
class="logo-img" class="logo-img"
:src="studioInfo.logo" :src="logoImage"
mode="aspectFit" mode="aspectFill"
/> />
<text v-else class="logo-placeholder">FC</text> <view v-else class="logo-placeholder">
<text>{{ studioName.slice(0, 1) || 'F' }}</text>
</view>
</view> </view>
<!-- Studio name --> <!-- Studio name -->
<text class="studio-name">{{ studioInfo?.name || 'Focus Core' }}</text> <text class="studio-name">{{ studioName }}</text>
</view> </view>
</view> </view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
import type { StudioConfig } from '@mp-pilates/shared' import type { StudioConfig } from '@mp-pilates/shared'
defineProps<{ const props = defineProps<{
studioInfo: StudioConfig | null studioInfo: StudioConfig | null
}>() }>()
const bannerImage = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/images/bannerBg.jpg' 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'
const bannerImage = computed(() => props.studioInfo?.bannerUrl || fallbackBannerImage)
const logoImage = computed(() => props.studioInfo?.logo || fallbackLogoImage)
const studioName = computed(() => props.studioInfo?.name || 'Focus Core')
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -89,15 +97,22 @@ const bannerImage = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/
} }
.logo-img { .logo-img {
width: 160rpx; width: 200rpx;
height: 160rpx; height: 200rpx;
border-radius: 50%;
} }
.logo-placeholder { .logo-placeholder {
width: 200rpx;
height: 200rpx;
border-radius: 50%;
font-size: 64rpx; font-size: 64rpx;
font-weight: 800; font-weight: 800;
color: #333; color: #333;
letter-spacing: 4rpx; letter-spacing: 4rpx;
display: flex;
align-items: center;
justify-content: center;
} }
.studio-name { .studio-name {

View File

@@ -3,7 +3,7 @@
<!-- Section header --> <!-- Section header -->
<view class="section-header"> <view class="section-header">
<text class="section-title">会员卡</text> <text class="section-title">会员卡</text>
<text class="section-action" @tap="goToAllCards">全部</text> <text class="section-action" @tap="goToAllCards">全部 </text>
</view> </view>
<!-- Loading skeleton --> <!-- Loading skeleton -->
@@ -30,41 +30,26 @@
class="card-row" class="card-row"
@tap="goToDetail(card.id)" @tap="goToDetail(card.id)"
> >
<!-- Card Cover horizontal premium design --> <!-- Card Cover image if available, gradient fallback -->
<view class="card-cover" :class="getCardCoverClass(card.type)"> <view class="card-cover" :class="card.coverUrl ? '' : getCardCoverClass(card.type)">
<!-- Left accent bar --> <image
<view class="cover-accent-bar" /> v-if="card.coverUrl"
class="card-cover-img"
<!-- Decorative circles --> :src="card.coverUrl"
<view class="cover-deco cover-deco--tl" /> mode="aspectFill"
<view class="cover-deco cover-deco--br" /> />
<template v-else>
<!-- CSS Icon --> <text class="cover-label">{{ card.type === CardTypeCategory.TRIAL ? '体验' : card.type === CardTypeCategory.DURATION ? '期限卡' : '次卡' }}</text>
<view class="cover-icon" :class="`cover-icon--${card.type}`" /> </template>
<!-- Right side: text content -->
<view class="cover-content">
<view class="cover-badge">
<text class="cover-badge-text">{{ getCardTypeLabel(card.type) }}</text>
</view>
<text class="cover-name">{{ card.name }}</text>
<view class="cover-price-row">
<text class="cover-currency">¥</text>
<text class="cover-price">{{ formatPrice(card.price) }}</text>
</view>
<text
v-if="card.originalPrice && card.originalPrice > card.price"
class="cover-original"
>
¥{{ formatPrice(card.originalPrice) }}
</text>
</view>
</view> </view>
<!-- Card info aligns with card-cover height --> <!-- Card info aligns with card-cover height -->
<view class="card-info"> <view class="card-info">
<view class="info-top"> <view class="info-top">
<text class="card-name">{{ card.name }}</text> <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> <text class="card-validity">有效期 {{ card.durationDays }} </text>
</view> </view>
<view class="info-bottom"> <view class="info-bottom">
@@ -73,7 +58,7 @@
<text class="card-times-unit">课时</text> <text class="card-times-unit">课时</text>
</view> </view>
<view class="price-row"> <view class="price-row">
<text class="price-current">¥{{ formatPrice(card.price) }}</text> <text class="price-current">¥{{ formatPrice(invite.price(card.price)) }}</text>
<text <text
v-if="card.originalPrice && card.originalPrice > card.price" v-if="card.originalPrice && card.originalPrice > card.price"
class="price-original" class="price-original"
@@ -84,6 +69,7 @@
</view> </view>
</view> </view>
<text v-if="invite.eligible" class="renew-tag">95 </text>
<!-- Arrow --> <!-- Arrow -->
<text class="card-arrow"></text> <text class="card-arrow"></text>
</view> </view>
@@ -97,23 +83,51 @@
</template> </template>
<script setup lang="ts"> <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 type { CardType } from '@mp-pilates/shared'
import { CardTypeCategory } from '@mp-pilates/shared'
import { get } from '../utils/request' import { get } from '../utils/request'
import { formatPrice, getCardTypeLabel, getCardCoverClass } 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 cardTypes = ref<CardType[]>([])
const loading = ref(false) 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() { async function fetchCardTypes() {
loading.value = true // Stale-While-Revalidate: only show skeleton on first load
// Subsequent refreshes silently update data in background
if (!hasLoaded.value) {
loading.value = true
}
try { try {
const result = await get<CardType[]>('/membership/card-types') const result = await get<CardType[]>('/membership/card-types')
cardTypes.value = result cardTypes.value = result
.filter((c) => c.isActive) .filter((c) => c.isActive)
.sort((a, b) => a.sortOrder - b.sortOrder) .sort((a, b) => a.sortOrder - b.sortOrder)
hasLoaded.value = true
} catch { } catch {
uni.showToast({ title: '加载会员卡失败', icon: 'none' }) // Only show error toast on first load; silent fail on background refresh
if (!hasLoaded.value) {
uni.showToast({ title: '加载会员卡失败', icon: 'none' })
}
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -134,442 +148,36 @@ function goToAllCards() {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.card-shop { .card-shop { margin: 36rpx 32rpx 0; }
background: #ffffff; .section-header { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; margin-bottom: 20rpx; }
margin-top: 16rpx; .section-title { font-size: 30rpx; font-weight: 500; color: #514943; }
padding-bottom: 20rpx; .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; } }
/* ── Section header ── */ .card-cover { width: 132rpx; height: 148rpx; border-radius: 16rpx; overflow: hidden; flex-shrink: 0; display: flex; align-items: center; justify-content: center; background: #f0e6de; }
.section-header { .card-cover-img { width: 100%; height: 100%; }
display: flex; .cover--times { background: #f0e6de; }
align-items: center; .cover--duration { background: #e9ede5; }
justify-content: space-between; .cover--trial { background: #f2e4e0; }
padding: 32rpx 32rpx 20rpx; .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; }
.section-title { .card-name-row { display: flex; align-items: baseline; gap: 8rpx; }
font-size: 36rpx; .card-name { min-width: 0; font-size: 27rpx; font-weight: 400; color: #514943; line-height: 1.5; overflow-wrap: anywhere; }
font-weight: 700; .renew-tag { flex-shrink: 0; font-size: 18rpx; color: #617d73; background: #edf2ec; padding: 3rpx 8rpx; border-radius: 6rpx; }
color: #222; .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; }
.section-action { .card-times-value { font-size: 24rpx; color: #6f655e; }
font-size: 26rpx; .card-times-unit { font-size: 20rpx; color: #8b817b; }
color: #999; .price-row { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8rpx; }
padding: 8rpx 24rpx; .price-current { font-size: 30rpx; font-weight: 500; color: #8b6c5b; font-variant-numeric: tabular-nums; }
border: 1rpx solid #ddd; .price-original { font-size: 20rpx; color: #a59b93; text-decoration: line-through; }
border-radius: 24rpx; .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; }
/* ── Card list ── */ .skeleton-line { height: 24rpx; width: 70%; border-radius: 8rpx; &--sub { width: 50%; } }
.card-list { .skeleton-line, .skeleton-card-cover { background: linear-gradient(90deg, #f0eae5 25%, #faf7f3 50%, #f0eae5 75%); background-size: 400% 100%; animation: shimmer 1.4s infinite; }
padding: 0 32rpx; .empty-state { padding: 48rpx 24rpx; border-radius: 24rpx; background: #f3efea; text-align: center; }
} .empty-text { font-size: 24rpx; color: #8b817b; }
.card-row {
display: flex;
align-items: center;
gap: 20rpx;
padding: 16rpx 0;
border-bottom: 1rpx solid rgba($brand-color, 0.08);
&:last-child {
border-bottom: none;
}
}
/* ══════════════════════════════════════════════════════════
CARD COVER — Horizontal premium card design
══════════════════════════════════════════════════════════ */
.card-cover {
width: 240rpx;
height: 130rpx;
border-radius: 16rpx;
overflow: hidden;
flex-shrink: 0;
position: relative;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 0;
/* Glow effect behind */
&::before {
content: '';
position: absolute;
top: -20rpx;
left: -20rpx;
right: -20rpx;
bottom: -20rpx;
background: inherit;
filter: blur(24rpx) brightness(0.8);
z-index: 0;
opacity: 0.4;
}
}
/* Left accent stripe */
.cover-accent-bar {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 6rpx;
background: rgba(255, 255, 255, 0.4);
z-index: 1;
}
/* Decorative circles */
.cover-deco {
position: absolute;
border-radius: 50%;
z-index: 0;
pointer-events: none;
&--tl {
width: 60rpx;
height: 60rpx;
top: -16rpx;
right: 20rpx;
background: rgba(255, 255, 255, 0.1);
}
&--br {
width: 80rpx;
height: 80rpx;
bottom: -24rpx;
left: -16rpx;
background: rgba(255, 255, 255, 0.07);
}
}
/* CSS-drawn icons */
.cover-icon {
width: 52rpx;
height: 52rpx;
position: relative;
z-index: 2;
flex-shrink: 0;
margin-left: 20rpx;
}
/* 次卡 — stacked cards */
.cover-icon--TIMES {
&::before {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 36rpx;
height: 24rpx;
border: 2rpx solid rgba(255, 255, 255, 0.85);
border-radius: 5rpx;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.12);
}
&::after {
content: '';
position: absolute;
bottom: 10rpx;
left: 50%;
transform: translateX(-50%);
width: 36rpx;
height: 24rpx;
border: 2rpx solid rgba(255, 255, 255, 1);
border-radius: 5rpx;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.2);
}
}
/* 月卡 — calendar */
.cover-icon--DURATION {
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 36rpx;
height: 30rpx;
border: 2rpx solid rgba(255, 255, 255, 0.9);
border-radius: 5rpx;
box-sizing: border-box;
}
&::after {
content: '';
position: absolute;
top: 9rpx;
left: 50%;
transform: translateX(-50%);
width: 24rpx;
height: 0;
border-top: 2rpx solid rgba(255, 255, 255, 1);
box-shadow:
-6rpx 5rpx 0 0 rgba(255, 255, 255, 0.9),
6rpx 5rpx 0 0 rgba(255, 255, 255, 0.9);
}
}
/* 体验卡 — star */
.cover-icon--TRIAL {
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 16rpx;
height: 16rpx;
border: 2rpx solid rgba(255, 255, 255, 1);
border-radius: 50%;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.25);
}
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 2rpx;
height: 42rpx;
background: rgba(255, 255, 255, 0.8);
box-shadow:
0 -12rpx 0 0 rgba(255, 255, 255, 0.8),
0 12rpx 0 0 rgba(255, 255, 255, 0.8),
-12rpx 0 0 0 rgba(255, 255, 255, 0.8),
12rpx 0 0 0 rgba(255, 255, 255, 0.8),
-8rpx -8rpx 0 0 rgba(255, 255, 255, 0.8),
8rpx -8rpx 0 0 rgba(255, 255, 255, 0.8),
-8rpx 8rpx 0 0 rgba(255, 255, 255, 0.8),
8rpx 8rpx 0 0 rgba(255, 255, 255, 0.8);
}
}
/* Card cover backgrounds */
.cover--times {
background: linear-gradient(135deg, #1e2340 0%, #2d2d5e 50%, #3a3a7a 100%);
}
.cover--duration {
background: linear-gradient(135deg, #4a1a6b 0%, #6c3483 50%, #8e4aaf 100%);
}
.cover--trial {
background: linear-gradient(135deg, #14527a 0%, #1a6fa0 50%, #48a9a6 100%);
}
/* Right side text content */
.cover-content {
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
padding: 0 16rpx 0 12rpx;
gap: 4rpx;
z-index: 2;
}
.cover-badge {
padding: 3rpx 10rpx;
border-radius: 8rpx;
background: rgba(255, 255, 255, 0.18);
border: 1rpx solid rgba(255, 255, 255, 0.28);
}
.cover-badge-text {
font-size: 16rpx;
color: rgba(255, 255, 255, 0.9);
font-weight: 600;
}
.cover-name {
font-size: 24rpx;
font-weight: 700;
color: #ffffff;
letter-spacing: 0.5rpx;
line-height: 1.2;
max-width: 130rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cover-price-row {
display: flex;
align-items: baseline;
gap: 2rpx;
}
.cover-currency {
font-size: 18rpx;
font-weight: 600;
color: rgba(255, 255, 255, 0.85);
}
.cover-price {
font-size: 28rpx;
font-weight: 800;
color: #ffffff;
line-height: 1;
}
.cover-original {
font-size: 16rpx;
color: rgba(255, 255, 255, 0.5);
text-decoration: line-through;
}
/* ── Card info — matches card-cover height ── */
.card-info {
flex: 1;
min-width: 0;
height: 130rpx;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.info-top {
display: flex;
flex-direction: column;
justify-content: center;
gap: 4rpx;
}
.info-bottom {
display: flex;
align-items: baseline;
gap: 16rpx;
}
.card-name {
font-size: 30rpx;
font-weight: 700;
color: $text-primary;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
letter-spacing: 0.5rpx;
line-height: 1.2;
}
.card-validity {
font-size: 23rpx;
color: $text-secondary;
line-height: 1.2;
}
.card-times {
display: flex;
align-items: baseline;
gap: 4rpx;
}
.card-times-value {
font-size: 34rpx;
font-weight: 800;
color: $brand-color;
line-height: 1;
}
.card-times-unit {
font-size: 20rpx;
color: $text-secondary;
font-weight: 500;
}
.price-row {
display: flex;
align-items: baseline;
gap: 6rpx;
}
.price-current {
font-size: 32rpx;
font-weight: 800;
color: $brand-color;
line-height: 1;
}
.price-original {
font-size: 20rpx;
color: $text-hint;
text-decoration: line-through;
}
/* Arrow */
.card-arrow {
font-size: 44rpx;
color: $text-hint;
flex-shrink: 0;
transform: scaleX(0.5);
transform-origin: center;
}
/* ── Skeleton ── */
.skeleton-row {
display: flex;
align-items: center;
gap: 20rpx;
padding: 20rpx 0;
border-bottom: 1rpx solid rgba($brand-color, 0.08);
}
.skeleton-card-cover {
width: 240rpx;
height: 130rpx;
border-radius: 16rpx;
flex-shrink: 0;
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 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%, #e8e8e8 50%, #f0f0f0 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
&--title {
width: 60%;
height: 30rpx;
}
&--sub {
width: 40%;
}
&--price {
width: 45%;
height: 36rpx;
}
}
/* ── Empty state ── */
.empty-state {
padding: 80rpx;
display: flex;
align-items: center;
justify-content: center;
}
.empty-text {
font-size: 28rpx;
color: $text-hint;
}
</style> </style>

View File

@@ -0,0 +1,76 @@
<template>
<view class="review">
<text class="eyebrow">课后 · 留一点感受</text>
<text class="title">这节课感觉怎么样</text>
<text class="hint">文字与标签仅你和馆主可见星级会计入首页匿名均分</text>
<view v-if="loading" class="hint">正在读取评价</view>
<view v-else-if="error" class="hint">{{ error }}<button class="secondary" @tap="load">重新加载</button></view>
<template v-else-if="review">
<text class="saved-stars">{{ '★'.repeat(review.rating) }}{{ '☆'.repeat(5 - review.rating) }}</text>
<view class="tags"><text v-for="tag in review.tags" :key="tag" class="tag selected">{{ tag }}</text></view>
<text class="comment">{{ review.comment || '谢谢你留下这份反馈。' }}</text>
<text class="hint">已评价 · {{ formatChinaDate(review.createdAt) }}</text>
</template>
<template v-else-if="canReview">
<view class="stars"><button v-for="n in 5" :key="n" :aria-label="n + ' 星'" :class="{ chosen: rating >= n }" @tap="rating = n">{{ rating >= n ? '★' : '☆' }}</button></view>
<text class="rating-label">{{ rating ? labels[rating - 1] : '轻触星星,为这节课评分' }}</text>
<view class="tags"><button v-for="tag in REVIEW_TAGS" :key="tag" class="tag" :class="{ selected: tags.includes(tag) }" @tap="toggle(tag)">{{ tag }}</button></view>
<text class="hint">可选最多 3 个标签</text>
<textarea v-model="comment" maxlength="200" placeholder="哪里让你有收获?还有什么可以做得更好?" />
<text class="counter">{{ comment.length }} / 200</text>
<picker :range="recommendations" @change="recommendation = Number($event.detail.value) - 1"><view class="recommend">你有多愿意推荐我们<text>{{ recommendation < 0 ? '选填 ' : recommendation + ' / 10 ' }}</text></view></picker>
<text class="hint">0 表示完全不愿意10 表示非常愿意</text>
<button class="primary" :loading="saving" :disabled="saving || !rating" @tap="submit">提交评价</button>
</template>
<text v-else class="hint">课程完成后即可评价</text>
</view>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { REVIEW_TAGS } from '@mp-pilates/shared'
import type { ClassReview } from '@mp-pilates/shared'
import { get, post } from '../utils/request'
import { formatChinaDate } from '../utils/format'
const props = defineProps<{ bookingId: string }>()
const review = ref<ClassReview | null>(null), canReview = ref(false), loading = ref(false), saving = ref(false), error = ref('')
const rating = ref(0), tags = ref<string[]>([]), comment = ref(''), recommendation = ref(-1)
const labels = ['不太满意', '有待改善', '整体还好', '很满意', '非常满意']
const recommendations = ['暂不填写', ...Array.from({ length: 11 }, (_, n) => String(n))]
let sequence = 0
async function load() {
const seq = ++sequence; loading.value = true; error.value = ''
try { const result = await get<{ review: ClassReview | null; canReview: boolean }>(`/booking/${props.bookingId}/review`); if (seq === sequence) { review.value = result.review; canReview.value = result.canReview } }
catch (e) { if (seq === sequence) error.value = e instanceof Error ? e.message : '评价加载失败' }
finally { if (seq === sequence) loading.value = false }
}
function toggle(tag: string) {
if (tags.value.includes(tag)) tags.value = tags.value.filter(t => t !== tag)
else if (tags.value.length < 3) tags.value = [...tags.value, tag]
else uni.showToast({ title: '最多选择 3 个标签', icon: 'none' })
}
async function submit() {
if (saving.value || !rating.value) return
saving.value = true
try { review.value = await post<ClassReview>(`/booking/${props.bookingId}/review`, { rating: rating.value, tags: tags.value, comment: comment.value, ...(recommendation.value >= 0 ? { recommendation: recommendation.value } : {}) }); uni.showToast({ title: '谢谢你的反馈', icon: 'success' }) }
catch (e) { uni.showToast({ title: e instanceof Error ? e.message : '提交失败,请重试', icon: 'none' }) }
finally { saving.value = false }
}
watch(() => props.bookingId, () => { review.value = null; rating.value = 0; tags.value = []; comment.value = ''; recommendation.value = -1; void load() }, { immediate: true })
</script>
<style lang="scss" scoped>
.review { margin: 28rpx 32rpx; padding: 32rpx; border: 1rpx solid #e7e0d7; border-radius: 28rpx; background: #fffdf9; color: #514943; }
.eyebrow { display:block; color:#8c7867; font-size:22rpx; letter-spacing:3rpx; }
.title { display:block; margin:18rpx 0; font-family:'Songti SC','STSong',serif; font-size:38rpx; }
.hint { display:block; font-size:23rpx; line-height:1.8; color:#82796e; }
.stars { display:flex; justify-content:space-between; margin:24rpx 0 8rpx; }
.stars button { padding:0; margin:0; width:96rpx; height:96rpx; line-height:96rpx; font-size:60rpx; background:transparent; color:#b7ac9c; &::after {border:0;} &.chosen {color:#a5824b;} }
.rating-label {display:block; text-align:center; color:#8c7867; font-size:24rpx; margin-bottom:24rpx;}
.tags {display:flex;flex-wrap:wrap;gap:14rpx;margin:18rpx 0;}
.tag {margin:0;padding:15rpx 20rpx;font-size:24rpx;line-height:1.5;border-radius:16rpx;background:#f4f1eb;color:#72695f;&::after{border:0;} &.selected{background:#e7eee7;color:#4d685c;}}
textarea {margin-top:22rpx;padding:24rpx;width:100%;height:190rpx;box-sizing:border-box;background:#f6f3ed;border-radius:18rpx;font-size:26rpx;line-height:1.7;}
.counter{display:block;text-align:right;color:#8b817b;font-size:21rpx;margin:10rpx 0 20rpx;}
.recommend{display:flex;justify-content:space-between;gap:16rpx;align-items:center;min-height:88rpx;border-top:1rpx solid #eee8e0;font-size:24rpx;}
.primary,.secondary{margin-top:24rpx;min-height:88rpx;line-height:88rpx;border-radius:22rpx;background:#617d70;color:#fff;font-size:27rpx;&::after{border:0;}}
.secondary{background:#eee9df;color:#645c52;}.primary[disabled]{background:#d5dcd2;color:#697365;}
.saved-stars{display:block;font-size:46rpx;color:#a5824b;margin-top:24rpx;}.comment{display:block;line-height:1.8;font-size:27rpx;margin:20rpx 0;white-space:pre-wrap;}
</style>

View File

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

View File

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

File diff suppressed because one or more lines are too long

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,54 @@
<template>
<view class="pass" :class="[tone, { 'pass--compact': compact }]">
<view class="pass-heading">
<view class="pass-identity"><text class="pass-kind">{{ getCardTypeLabel(membership.cardType.type) }} · MEMBERSHIP</text><text class="pass-name">{{ membership.cardType.name }}</text></view>
<text class="pass-mark">{{ compact ? '查看 ' : '有效' }}</text>
</view>
<view class="pass-balance">
<text class="balance-label">{{ unlimited ? '有效期内' : '剩余' }}</text>
<text class="balance-number">{{ unlimited ? '不限次' : membership.remainingTimes }}</text>
<text v-if="!unlimited" class="balance-unit"></text>
<text v-if="!unlimited && total !== null" class="usage-label">已用 {{ used }} / {{ total }} </text>
<text v-else-if="unlimited" class="usage-label">{{ days }} 天后到期</text>
</view>
<view v-if="!unlimited && total !== null && total > 0" class="usage-track" :aria-label="`已用${used}次,共${total}次`"><view class="usage-fill" :style="{ width: `${progress}%` }" /></view>
<view v-else class="pass-rule" />
<view class="pass-dates"><text v-if="!compact">{{ membership.startDate.slice(0, 10).replace(/-/g, '.') }} 起</text><text :class="{ 'expiry-soon': days <= 7 }">{{ membership.expireDate.slice(0, 10).replace(/-/g, '.') }} 到期</text></view>
<slot />
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { CardTypeCategory, type MembershipWithCardType } from '@mp-pilates/shared'
import { getCardTypeLabel, getMembershipTotalTimes, getMembershipUsedTimes } from '../utils/format'
const props = defineProps<{ membership: MembershipWithCardType; compact?: boolean; now: number }>()
const unlimited = computed(() => props.membership.remainingTimes === null)
const total = computed(() => getMembershipTotalTimes(props.membership))
const used = computed(() => getMembershipUsedTimes(props.membership))
const progress = computed(() => total.value && total.value > 0 ? Math.min(100, Math.max(0, used.value / total.value * 100)) : 0)
const days = computed(() => Math.max(0, Math.ceil((new Date(props.membership.expireDate).getTime() - props.now) / 86400000)))
const tone = computed(() => props.membership.cardType.type === CardTypeCategory.DURATION ? 'pass--sage' : props.membership.cardType.type === CardTypeCategory.TRIAL ? 'pass--clay' : 'pass--sand')
</script>
<style scoped lang="scss">
.pass { --pass-bg: #f0e6d8; --pass-ink: #655240; --pass-line: #d7c3a9; --pass-track: #e3d4c1; box-sizing: border-box; padding: 30rpx; border-radius: 24rpx; background: var(--pass-bg); color: var(--pass-ink); border: 1rpx solid var(--pass-line); }
.pass--sage { --pass-bg: #e7eee4; --pass-ink: #465f4d; --pass-line: #b8c9b3; --pass-track: #d4dfce; }
.pass--clay { --pass-bg: #f2e5df; --pass-ink: #845c4a; --pass-line: #d9b9aa; --pass-track: #e7d0c4; }
.pass-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 20rpx; }
.pass-identity { min-width: 0; flex: 1; }
.pass-kind { display: block; font-size: 18rpx; letter-spacing: 2rpx; }
.pass-name { display: block; margin-top: 12rpx; font-family: 'Songti SC', 'STSong', serif; font-size: 34rpx; line-height: 1.4; overflow-wrap: anywhere; }
.pass-mark { flex-shrink: 0; font-size: 21rpx; padding-top: 4rpx; }
.pass-balance { display: flex; align-items: baseline; flex-wrap: wrap; gap: 10rpx; margin-top: 30rpx; }
.balance-label, .balance-unit { font-size: 23rpx; }
.balance-number { font-family: 'Baskerville', 'Times New Roman', serif; font-size: 60rpx; line-height: 1.2; font-variant-numeric: tabular-nums; }
.usage-label { font-size: 23rpx; margin-left: auto; }
.usage-track { height: 7rpx; border-radius: 8rpx; background: var(--pass-track); overflow: hidden; margin-top: 22rpx; }
.usage-fill { height: 100%; background: var(--pass-ink); border-radius: 8rpx; }
.pass-rule { height: 1rpx; margin-top: 22rpx; background: var(--pass-line); }
.pass-dates { display: flex; justify-content: space-between; gap: 16rpx; margin-top: 18rpx; font-size: 22rpx; font-variant-numeric: tabular-nums; }
.expiry-soon { font-weight: 600; }
.pass--compact { height: 100%; padding: 24rpx; border-radius: 20rpx; .pass-name { font-size: 30rpx; margin-top: 8rpx; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .pass-balance { margin-top: 18rpx; } .balance-number { font-size: 42rpx; } .usage-label { font-size: 21rpx; } .pass-dates { justify-content: flex-end; font-size: 21rpx; margin-top: 14rpx; } .usage-track, .pass-rule { margin-top: 16rpx; } }
</style>

View File

@@ -0,0 +1,19 @@
<template>
<view class="progress">
<view class="bar"><view class="fill" :style="{ width: `${percent}%` }" /></view>
<text class="label">{{ step }}/{{ total }}</text>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{ step: number; total: number }>()
const percent = computed(() => Math.round((props.step / props.total) * 100))
</script>
<style lang="scss" scoped>
.progress { display: flex; align-items: center; gap: 16rpx; margin: 8rpx 0 24rpx; }
.bar { flex: 1; height: 8rpx; background: #efe8e1; border-radius: 8rpx; overflow: hidden; }
.fill { height: 100%; background: #6b8276; }
.label { font-size: 22rpx; color: #8a7b6e; }
</style>

View File

@@ -0,0 +1,69 @@
<template>
<canvas canvas-id="portraitRadar" class="radar" />
</template>
<script setup lang="ts">
import { getCurrentInstance, onMounted, watch } from 'vue'
import { BODY_DIMENSION_LABELS, type BodyPortraitScores } from '@mp-pilates/shared'
const props = defineProps<{ scores: BodyPortraitScores }>()
const instance = getCurrentInstance()
const labels = ['肩颈', '脊柱', '核心', '髋骨盆', '下肢']
const keys = ['cervicalShoulder', 'spinalMobility', 'coreControl', 'hipPelvis', 'lowerLimb'] as const
function draw() {
const ctx = uni.createCanvasContext('portraitRadar', instance?.proxy)
const w = 280
const h = 280
const cx = w / 2
const cy = h / 2
const radius = 96
ctx.clearRect(0, 0, w, h)
ctx.setStrokeStyle('#d9cfc5')
ctx.setFillStyle('#edf2ec')
for (let ring = 1; ring <= 4; ring++) {
ctx.beginPath()
for (let i = 0; i < 5; i++) {
const angle = -Math.PI / 2 + (Math.PI * 2 * i) / 5
const x = cx + Math.cos(angle) * radius * (ring / 4)
const y = cy + Math.sin(angle) * radius * (ring / 4)
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
}
ctx.closePath()
ctx.stroke()
}
ctx.setFillStyle('rgba(107,130,118,0.35)')
ctx.setStrokeStyle('#6b8276')
ctx.beginPath()
keys.forEach((key, i) => {
const angle = -Math.PI / 2 + (Math.PI * 2 * i) / 5
const value = Math.max(8, props.scores[key]) / 100
const x = cx + Math.cos(angle) * radius * value
const y = cy + Math.sin(angle) * radius * value
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
})
ctx.closePath()
ctx.fill()
ctx.stroke()
ctx.setFillStyle('#5d5148')
ctx.setFontSize(11)
ctx.setTextAlign('center')
labels.forEach((label, i) => {
const angle = -Math.PI / 2 + (Math.PI * 2 * i) / 5
const x = cx + Math.cos(angle) * (radius + 22)
const y = cy + Math.sin(angle) * (radius + 22)
ctx.fillText(label, x, y)
})
ctx.draw()
}
onMounted(draw)
watch(() => props.scores, draw, { deep: true })
void BODY_DIMENSION_LABELS
</script>
<style lang="scss" scoped>
.radar { width: 560rpx; height: 560rpx; margin: 0 auto; }
</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,32 +1,17 @@
<template> <template>
<view class="profile-menu"> <view class="profile-menu">
<template v-for="item in menuItems" :key="item.key"> <slot />
<!-- Separator -->
<view v-if="item.type === 'separator'" class="profile-menu__separator" />
<!-- Menu item --> <view class="profile-menu__links">
<view <template v-for="item in menuItems" :key="item.key">
v-else <view v-if="item.type === 'separator'" class="profile-menu__separator" />
class="profile-menu__item" <view v-else class="profile-menu__item" :class="{ 'profile-menu__item--admin': item.isAdmin }"
:class="{ 'profile-menu__item--admin': item.isAdmin }" hover-class="profile-menu__item--hover" hover-stay-time="150" @tap="handleTap(item)">
hover-class="profile-menu__item--hover" <text class="profile-menu__title">{{ item.title }}</text>
hover-stay-time="150" <text class="profile-menu__arrow"></text>
@tap="handleTap(item)" </view>
> </template>
<view </view>
class="profile-menu__icon-wrap"
:class="[
`profile-menu__icon-wrap--${item.key}`,
{ 'profile-menu__icon-wrap--admin': item.isAdmin },
]"
/>
<text class="profile-menu__title" :class="{ 'profile-menu__title--admin': item.isAdmin }">
{{ item.title }}
</text>
<text v-if="item.badge" class="profile-menu__badge">{{ item.badge }}</text>
<text class="profile-menu__arrow"></text>
</view>
</template>
</view> </view>
</template> </template>
@@ -39,8 +24,7 @@ interface MenuItem {
title?: string title?: string
path?: string path?: string
isAdmin?: boolean isAdmin?: boolean
badge?: string action?: 'clear' | 'notifications'
action?: 'clear' | 'about'
requireAuth?: boolean requireAuth?: boolean
} }
@@ -51,19 +35,24 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'clear-cache'): void (e: 'clear-cache'): void
(e: 'about'): void
(e: 'require-login'): void (e: 'require-login'): void
(e: 'open-notifications'): void
}>() }>()
const menuItems = computed<MenuItem[]>(() => { const menuItems = computed<MenuItem[]>(() => {
const items: MenuItem[] = [ const items: MenuItem[] = [
{ { key: 'progress', type: 'item', title: '我的成长档案', path: '/pages/profile/progress', requireAuth: true },
key: 'membership', { key: 'portrait', type: 'item', title: '身体状态评估', path: '/pages/portrait/index' },
type: 'item', { key: 'plan', type: 'item', title: '我的改善计划', path: '/pages/portrait/plan', requireAuth: true },
title: '我的会员卡', ...(props.isAdmin
path: '/pages/profile/membership', ? [{
requireAuth: true, key: 'teaching-schedule',
}, type: 'item' as const,
title: '我的课表',
path: '/pages/profile/teaching-schedule',
requireAuth: true,
}]
: []),
{ {
key: 'bookings', key: 'bookings',
type: 'item', type: 'item',
@@ -78,6 +67,13 @@ const menuItems = computed<MenuItem[]>(() => {
path: '/pages/profile/info', path: '/pages/profile/info',
requireAuth: true, requireAuth: true,
}, },
{
key: 'notifications',
type: 'item',
title: '消息提醒设置',
action: 'notifications',
requireAuth: true,
},
{ {
key: 'sep1', key: 'sep1',
type: 'separator', type: 'separator',
@@ -88,12 +84,6 @@ const menuItems = computed<MenuItem[]>(() => {
title: '清除缓存', title: '清除缓存',
action: 'clear', action: 'clear',
}, },
{
key: 'about',
type: 'item',
title: '关于我们',
action: 'about',
},
] ]
if (props.isAdmin) { if (props.isAdmin) {
@@ -118,8 +108,8 @@ function handleTap(item: MenuItem) {
} }
if (item.action === 'clear') { if (item.action === 'clear') {
emit('clear-cache') emit('clear-cache')
} else if (item.action === 'about') { } else if (item.action === 'notifications') {
emit('about') emit('open-notifications')
} else if (item.path) { } else if (item.path) {
uni.navigateTo({ url: item.path }) uni.navigateTo({ url: item.path })
} }
@@ -128,237 +118,12 @@ function handleTap(item: MenuItem) {
<style lang="scss" scoped> <style lang="scss" scoped>
.profile-menu { .profile-menu {
background: $bg-card; &__links { margin: 24rpx 32rpx 0; border-radius: 26rpx; background: #fff; overflow: hidden; }
border-radius: $radius-lg; &__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; } }
margin: $spacing-lg $spacing-lg 0; &__item--hover { background: #f4f1ec; }
overflow: hidden; &__item--admin { background: #eff3ed; .profile-menu__title { color: #617d73; } }
&__title { flex: 1; font-size: 27rpx; font-weight: 400; color: #6f655e; }
&__separator { &__arrow { font-size: 30rpx; color: #b2a79e; line-height: 1; }
height: $spacing-sm; &__separator { height: 12rpx; background: #fbf9f6; }
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: 56rpx;
height: 56rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-right: $spacing-md;
position: relative;
background: rgba($brand-color, 0.06);
// ─── Pure CSS Icons ────────────────────────────────
// 会员卡 — 圆角矩形卡片 + 横线
&--membership {
background: rgba($accent-color, 0.10);
&::before {
content: '';
width: 26rpx;
height: 18rpx;
border: 2.5rpx solid $accent-color;
border-radius: 4rpx;
box-sizing: border-box;
}
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, 1rpx);
width: 16rpx;
height: 0;
border-top: 2.5rpx solid $accent-color;
}
}
// 预约 — 日历(矩形 + 顶部两个小竖线)
&--bookings {
background: rgba($brand-color, 0.06);
&::before {
content: '';
width: 24rpx;
height: 22rpx;
border: 2.5rpx solid $brand-color;
border-radius: 4rpx;
border-top-width: 5rpx;
box-sizing: border-box;
}
&::after {
content: '';
position: absolute;
top: 14rpx;
left: 50%;
transform: translateX(-50%);
width: 10rpx;
height: 0;
border-top: 2.5rpx solid $brand-color;
// 用 box-shadow 模拟两个竖线
box-shadow:
-4rpx -7rpx 0 0 $brand-color,
4rpx -7rpx 0 0 $brand-color;
}
}
// 个人信息 — 人形(圆 + 肩弧)
&--info {
background: rgba($brand-color, 0.06);
&::before {
content: '';
width: 12rpx;
height: 12rpx;
border: 2.5rpx solid $brand-color;
border-radius: 50%;
position: absolute;
top: 16rpx;
left: 50%;
transform: translateX(-50%);
box-sizing: border-box;
}
&::after {
content: '';
width: 22rpx;
height: 10rpx;
border: 2.5rpx solid $brand-color;
border-bottom: none;
border-radius: 12rpx 12rpx 0 0;
position: absolute;
bottom: 13rpx;
left: 50%;
transform: translateX(-50%);
box-sizing: border-box;
}
}
// 清除缓存 — 旋转的刷新箭头(圆弧)
&--clear {
background: rgba($text-hint, 0.08);
&::before {
content: '';
width: 20rpx;
height: 20rpx;
border: 2.5rpx solid $text-secondary;
border-radius: 50%;
border-right-color: transparent;
box-sizing: border-box;
}
&::after {
content: '';
position: absolute;
top: 14rpx;
right: 15rpx;
width: 0;
height: 0;
border-left: 5rpx solid $text-secondary;
border-top: 4rpx solid transparent;
border-bottom: 4rpx solid transparent;
}
}
// 关于我们 — 圆形中心一个点 + 竖线info 标记)
&--about {
background: rgba($text-hint, 0.08);
&::before {
content: '';
width: 22rpx;
height: 22rpx;
border: 2.5rpx solid $text-secondary;
border-radius: 50%;
box-sizing: border-box;
}
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 2.5rpx;
height: 8rpx;
background: $text-secondary;
border-radius: 1rpx;
box-shadow: 0 -6rpx 0 0 $text-secondary;
}
}
// 管理中心 — 齿轮(圆 + 四个刻度)
&--admin {
background: rgba($accent-color, 0.12);
&::before {
content: '';
width: 14rpx;
height: 14rpx;
border: 2.5rpx solid $accent-color;
border-radius: 50%;
box-sizing: border-box;
}
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 24rpx;
height: 24rpx;
transform: translate(-50%, -50%);
// 四条刻度线用 box-shadow 实现
background:
linear-gradient($accent-color, $accent-color) center top / 2.5rpx 5rpx no-repeat,
linear-gradient($accent-color, $accent-color) center bottom / 2.5rpx 5rpx no-repeat,
linear-gradient($accent-color, $accent-color) left center / 5rpx 2.5rpx no-repeat,
linear-gradient($accent-color, $accent-color) right center / 5rpx 2.5rpx no-repeat;
}
}
}
&__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;
}
} }
</style> </style>

View File

@@ -1,98 +1,38 @@
<template> <template>
<view class="quick-entry"> <view class="quick-entry">
<!-- Not logged in --> <!-- Not logged in -->
<view v-if="!userStore.loggedIn" class="entry-card login-card" @tap="handleLogin"> <view v-if="!userStore.loggedIn || userStore.memberships.length === 0" class="entry-pill pill-login" @tap="handlePortrait">
<view class="entry-content"> <text class="pill-label">3 分钟了解自己的身体状态</text>
<view class="entry-left"> <view class="pill-action action-login">
<view class="entry-icon-wrap login-icon"> <text class="pill-action-text">开始评估</text>
<view class="icon-user" />
</view>
<view class="entry-text">
<text class="entry-title">欢迎来到工作室</text>
<text class="entry-subtitle">登录后即可预约课程</text>
</view>
</view>
<view class="entry-btn login-btn">
<text class="entry-btn-text">微信登录</text>
</view>
</view> </view>
</view> </view>
<!-- Logged in, no memberships at all new user --> <!-- Has valid active card -->
<view
v-else-if="userStore.loggedIn && userStore.memberships.length === 0"
class="entry-card trial-card"
@tap="handleTrialEntry"
>
<view class="entry-content">
<view class="entry-left">
<view class="entry-icon-wrap trial-icon">
<view class="icon-star" />
</view>
<view class="entry-text">
<text class="entry-title">初次体验</text>
<text class="entry-subtitle">专属体验课了解普拉提</text>
</view>
</view>
<view class="entry-btn trial-btn">
<text class="entry-btn-text">预约体验课</text>
</view>
</view>
<view class="card-badge trial-badge">新会员专享</view>
</view>
<!-- Has valid active card + running low warning -->
<template v-else-if="userStore.hasValidMembership"> <template v-else-if="userStore.hasValidMembership">
<view class="entry-card active-card" @tap="handleBooking"> <view class="entry-pill pill-active" @tap="handleBooking">
<view class="entry-content"> <text class="pill-label pill-label-active">{{ activeMembershipLabel }}</text>
<view class="entry-left"> <view class="pill-action action-book">
<view class="entry-icon-wrap active-icon"> <text class="pill-action-text">预约课程</text>
<view class="icon-clock" />
</view>
<view class="entry-text">
<text class="entry-title">一键约课</text>
<text class="entry-subtitle">{{ activeMembershipLabel }}</text>
</view>
</view>
<view class="entry-btn book-btn">
<text class="entry-btn-text">立即预约</text>
</view>
</view>
<!-- Running low badge -->
<view v-if="isRunningLow" class="card-badge low-badge">
仅剩 {{ lowestRemainingTimes }}
</view> </view>
</view> </view>
<!-- Renew reminder if running low --> <!-- Running low: thin accent strip -->
<view v-if="isRunningLow" class="renew-tip" @tap="scrollToCardShop"> <view v-if="renewStripText" class="renew-strip" @tap="handleRenew">
<view class="renew-tip-icon"> <text class="renew-strip-text">{{ renewStripText }}</text>
<view class="icon-warning" /> <text class="renew-strip-arrow"></text>
</view>
<text class="renew-tip-text">课次即将用完点击续卡保持练习节奏</text>
<text class="renew-tip-action">续卡 </text>
</view> </view>
</template> </template>
<!-- Has memberships but none active buy card --> <!-- Has memberships but none active buy card -->
<view <view
v-else v-else
class="entry-card expired-card" class="entry-pill pill-expired"
@tap="scrollToCardShop" @tap="handleRenew"
> >
<view class="entry-content"> <text class="pill-label">暂无可用会员卡</text>
<view class="entry-left"> <view class="pill-action action-renew">
<view class="entry-icon-wrap expired-icon"> <text class="pill-action-text">续卡</text>
<view class="icon-card" />
</view>
<view class="entry-text">
<text class="entry-title">续费会员卡</text>
<text class="entry-subtitle">您的卡已到期续卡继续练习</text>
</view>
</view>
<view class="entry-btn renew-btn">
<text class="entry-btn-text">购买会员卡</text>
</view>
</view> </view>
</view> </view>
</view> </view>
@@ -101,7 +41,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useUserStore } from '../stores/user' import { useUserStore } from '../stores/user'
import { CardTypeCategory } from '@mp-pilates/shared' import { getErrorMessage } from '../utils/auth'
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'scroll-to-card-shop'): void (e: 'scroll-to-card-shop'): void
@@ -115,15 +55,18 @@ async function handleLogin() {
loading.value = true loading.value = true
try { try {
await userStore.loginWithSetup() await userStore.loginWithSetup()
} catch { } catch (err: unknown) {
uni.showToast({ title: '登录失败,请重试', icon: 'none' }) uni.showToast({ title: getErrorMessage(err, '登录失败,请重试'), icon: 'none' })
} finally { } finally {
loading.value = false loading.value = false
} }
} }
function handlePortrait() {
uni.navigateTo({ url: '/pages/portrait/index' })
}
function handleTrialEntry() { function handleTrialEntry() {
// Navigate to the first TRIAL card detail page
uni.navigateTo({ url: '/pages/card/detail?trial=1' }) uni.navigateTo({ url: '/pages/card/detail?trial=1' })
} }
@@ -135,397 +78,55 @@ function scrollToCardShop() {
emit('scroll-to-card-shop') 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 activeMembershipLabel = computed(() => {
const active = userStore.activeMemberships const active = userStore.activeMemberships
if (!active.length) return '' if (!active.length) return ''
const m = active[0] const m = active[0]
const cardName = m.cardType.name const cardName = m.cardType.name
if (m.cardType.type === CardTypeCategory.TIMES && m.remainingTimes !== null) { if (m.remainingTimes !== null) {
return `${cardName} · 剩余 ${m.remainingTimes}` return `${cardName} · 剩余 ${m.remainingTimes}`
} }
const expire = new Date(m.expireDate) const expire = new Date(m.expireDate)
const today = new Date() const today = new Date()
const daysLeft = Math.ceil((expire.getTime() - today.getTime()) / 86400000) const daysLeft = Math.ceil((expire.getTime() - today.getTime()) / 86400000)
return `${cardName} · 剩余 ${daysLeft}` return `${cardName} · 剩余 ${daysLeft}`
}) })
// Check if any TIMES card has ≤ 2 remaining const renewStripText = computed(() => {
const isRunningLow = computed(() => { const hint = userStore.renewalHint
return userStore.activeMemberships.some( if (!hint || !userStore.hasValidMembership) return ''
(m) => if (hint.kind === 'times_low') {
m.cardType.type === CardTypeCategory.TIMES && return `仅剩 ${hint.remainingTimes ?? 0} 次 · 续卡保持节奏`
m.remainingTimes !== null && }
m.remainingTimes <= 2, if (hint.kind === 'days_low') {
) return `还剩 ${hint.daysLeft ?? 0} 天到期 · 续卡不中断`
}) }
if (hint.kind === 'trial_low') {
const lowestRemainingTimes = computed(() => { return '体验课将尽 · 选购会员卡'
const timesCards = userStore.activeMemberships.filter( }
(m) => return ''
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))
}) })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.quick-entry { .quick-entry { margin: 24rpx 32rpx 0; }
margin: 24rpx 24rpx 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; }
.entry-card { .pill-label { flex: 1; min-width: 0; font-size: 26rpx; line-height: 1.6; color: #6f5c50; overflow-wrap: anywhere; }
position: relative; .pill-label-active { color: #526b5d; }
border-radius: 16rpx; .pill-action { flex-shrink: 0; min-height: 68rpx; padding: 0 24rpx; border-radius: 999rpx; display: flex; align-items: center; justify-content: center; background: #6b8276; }
padding: 36rpx 32rpx; .pill-action-text { font-size: 24rpx; font-weight: 400; color: #fff; white-space: nowrap; }
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.08); .renew-strip { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; padding: 18rpx 12rpx 0; }
overflow: hidden; .renew-strip-text { font-size: 22rpx; color: #8b817b; line-height: 1.5; }
} .renew-strip-arrow { font-size: 28rpx; color: #8b817b; }
.login-card {
background: linear-gradient(135deg, #1a1a2e 0%, #2d2d5e 100%);
}
.trial-card {
background: linear-gradient(135deg, #2d2d5e 0%, #4a3f7a 100%);
}
.active-card {
background: linear-gradient(135deg, #2a3a4a 0%, #1a2a3a 100%);
}
.expired-card {
background: linear-gradient(135deg, #4a4a4a 0%, #2a2a2a 100%);
}
.entry-content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24rpx;
}
.entry-left {
display: flex;
align-items: center;
gap: 28rpx;
flex: 1;
}
.entry-icon-wrap {
width: 88rpx;
height: 88rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
position: relative;
}
.login-icon {
background: rgba(255, 255, 255, 0.12);
}
.trial-icon {
background: rgba(255, 215, 0, 0.2);
}
.active-icon {
background: rgba(168, 196, 206, 0.25);
}
.expired-icon {
background: rgba(255, 255, 255, 0.12);
}
/* ── Icon shapes (pure CSS) ── */
/* User icon: head + shoulders */
.icon-user {
position: relative;
width: 36rpx;
height: 36rpx;
&::before {
content: '';
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 20rpx;
height: 20rpx;
border-radius: 50%;
background: #fff;
}
&::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 28rpx;
height: 14rpx;
border-radius: 14rpx 14rpx 0 0;
background: #fff;
}
}
/* Star icon - diamond shape */
.icon-star {
position: relative;
width: 32rpx;
height: 32rpx;
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(45deg);
width: 24rpx;
height: 24rpx;
background: #ffd700;
}
}
/* Clock icon - circle with dot */
.icon-clock {
position: relative;
width: 36rpx;
height: 36rpx;
border-radius: 50%;
border: 3rpx solid #fff;
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 8rpx;
height: 8rpx;
border-radius: 50%;
background: #fff;
}
}
/* Card icon */
.icon-card {
position: relative;
width: 36rpx;
height: 26rpx;
border-radius: 4rpx;
border: 3rpx solid #fff;
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 12rpx;
height: 6rpx;
border-radius: 2rpx;
background: #fff;
}
}
/* 闪电 icon — 更有能量感 */
.icon-warning {
position: relative;
width: 22rpx;
height: 22rpx;
&::before {
content: '';
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%) rotate(-15deg);
width: 0;
height: 0;
border-left: 8rpx solid transparent;
border-right: 8rpx solid transparent;
border-bottom: 18rpx solid #ffffff;
}
&::after {
content: '';
position: absolute;
bottom: 2rpx;
left: 50%;
transform: translateX(-50%);
width: 10rpx;
height: 3rpx;
background: rgba(255, 255, 255, 0.5);
border-radius: 2rpx;
}
}
.entry-text {
flex: 1;
min-width: 0;
}
.entry-title {
display: block;
font-size: 34rpx;
font-weight: 600;
color: #ffffff;
margin-bottom: 8rpx;
letter-spacing: 1rpx;
}
.entry-subtitle {
display: block;
font-size: 24rpx;
color: rgba(255, 255, 255, 0.6);
line-height: 1.4;
}
.entry-btn {
flex-shrink: 0;
padding: 18rpx 36rpx;
border-radius: 40rpx;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(255,255,255,0.2) 0%, transparent 100%);
opacity: 0.5;
}
}
.entry-btn-text {
font-size: 28rpx;
font-weight: 600;
white-space: nowrap;
position: relative;
z-index: 1;
}
.login-btn,
.trial-btn,
.book-btn {
background: $primary-color;
}
.renew-btn {
background: #666;
}
.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: linear-gradient(135deg, #ffd700, #ffaa00);
color: #1a1a2e;
}
.low-badge {
background: linear-gradient(135deg, #FF6B35 0%, #FF8E53 100%);
color: #ffffff;
box-shadow: 0 4rpx 12rpx rgba(255, 107, 53, 0.35);
}
/* Renew tip bar — 充满能量的激励配色 */
.renew-tip {
display: flex;
align-items: center;
gap: 16rpx;
margin-top: 16rpx;
padding: 22rpx 28rpx;
background: linear-gradient(135deg, #FF6B35 0%, #FF8E53 60%, #FFAA5C 100%);
border-radius: 16rpx;
box-shadow: 0 6rpx 20rpx rgba(255, 107, 53, 0.3);
position: relative;
overflow: hidden;
/* 右上角光晕装饰 */
&::before {
content: '';
position: absolute;
top: -20rpx;
right: -20rpx;
width: 120rpx;
height: 120rpx;
background: radial-gradient(circle, rgba(255, 255, 255, 0.25) 0%, transparent 70%);
pointer-events: none;
}
/* 底部微光 */
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 40%;
background: linear-gradient(to top, rgba(0, 0, 0, 0.08) 0%, transparent 100%);
pointer-events: none;
}
}
.renew-tip-icon {
width: 52rpx;
height: 52rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
position: relative;
z-index: 1;
backdrop-filter: blur(4rpx);
}
.renew-tip-text {
flex: 1;
font-size: 26rpx;
color: #ffffff;
line-height: 1.5;
font-weight: 500;
position: relative;
z-index: 1;
text-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.1);
}
.renew-tip-action {
font-size: 26rpx;
color: #ffffff;
font-weight: 700;
flex-shrink: 0;
position: relative;
z-index: 1;
letter-spacing: 1rpx;
/* 箭头增强感 */
text-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.15);
}
</style> </style>

View File

@@ -0,0 +1,9 @@
<template><view v-if="summary && summary.count > 0" class="summary"><view><text class="label">来自真实课后反馈</text><text class="caption">每一次被听见,让练习更贴近你。</text></view><view class="score"><text>{{ summary.average }}<text class="star">★</text></text><text class="count">{{ summary.count }} 份评价</text></view></view></template>
<script setup lang="ts">
import { ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { get } from '../utils/request'
const summary = ref<{ count: number; average: number | null } | null>(null)
onShow(async () => { try { summary.value = await get('/reviews/summary') } catch { summary.value = null } })
</script>
<style scoped>.summary{margin:24rpx 32rpx;padding:26rpx;display:flex;justify-content:space-between;align-items:center;gap:20rpx;border:1rpx solid #e2e5d9;border-radius:24rpx;background:#f1f3e9;}.label{display:block;font-size:26rpx;color:#65735b;}.caption{display:block;font-size:22rpx;color:#8a8d7c;line-height:1.7;margin-top:10rpx;}.score{flex-shrink:0;text-align:right;font-size:46rpx;color:#6b785c;font-family:'Baskerville',serif;}.star{font-size:27rpx;margin-left:8rpx;color:#a1874b;}.count{display:block;font-size:21rpx;font-family:inherit;line-height:1.8;color:#858874;}</style>

View File

@@ -0,0 +1,16 @@
<template>
<view class="notice">
<text class="title">建议先确认运动条件</text>
<text class="body">{{ message }}</text>
</view>
</template>
<script setup lang="ts">
defineProps<{ message: string }>()
</script>
<style lang="scss" scoped>
.notice { margin: 24rpx 0; padding: 28rpx; border-radius: 20rpx; background: #f7eee8; }
.title { display: block; font-size: 30rpx; color: #7a4e3e; margin-bottom: 12rpx; }
.body { display: block; font-size: 26rpx; line-height: 1.7; color: #8a6458; }
</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,94 +1,29 @@
<template> <template>
<view class="slot-card-wrapper" :class="[`status-${statusClass}`]" @tap="emit('cardTap', timeSlot)"> <view class="slot-card" :class="`slot-card--${statusClass}`" @tap="emit('cardTap', timeSlot)">
<!-- Ticket background image --> <view class="slot-heading">
<image <view class="slot-time">
class="ticket-bg" <text class="time-start">{{ startTimeDisplay }}</text>
src="/static/courseBg.png" <text class="time-end"> {{ endTimeDisplay }}</text>
mode="aspectFill"
/>
<!-- Card content overlay -->
<view class="ticket-content">
<!-- Top section: Time row (like flight ticket) -->
<view class="ticket-top">
<!-- Left: Start time -->
<view class="time-block">
<text class="time-main">{{ startTimeDisplay }}</text>
<text class="time-label">{{ timeSlot.date }}</text>
</view>
<!-- Center: Duration + icon -->
<view class="duration-block">
<view class="duration-line">
<view class="line-dot" />
<view class="line-dash" />
<view class="duration-icon">
<text class="icon-text"></text>
</view>
<view class="line-dash" />
<view class="line-dot" />
</view>
<text class="duration-text">{{ durationMin }}分钟</text>
</view>
<!-- Right: End time -->
<view class="time-block time-block--right">
<text class="time-main">{{ endTimeDisplay }}</text>
<view class="capacity-tag" :class="capacityClass">
<text class="capacity-text">{{ capacityLabel }}</text>
</view>
</view>
</view> </view>
<text class="slot-duration">{{ durationMin }} 分钟</text>
<!-- Dashed tear-off line --> </view>
<view class="tear-line" /> <view class="slot-body">
<view class="course-info">
<!-- Bottom section: Course name + Action --> <text class="course-name">普拉提私教</text>
<view class="ticket-bottom"> <text class="slot-status">{{ capacityLabel }}</text>
<view class="course-info"> </view>
<text class="course-name">普拉提私教</text> <view class="action-area">
</view> <template v-if="timeSlot.isBookedByMe">
<text class="booked-label">已预约</text>
<!-- Action area --> <view v-if="timeSlot.status === TimeSlotStatus.OPEN" class="cancel-link" @tap.stop="emit('cancel', timeSlot)">
<view class="action-area"> <text>取消预约</text>
<!-- Expired --> </view>
<template v-if="isPast && !timeSlot.isBookedByMe"> </template>
<view class="action-badge badge-expired"> <button v-else-if="!isPast && timeSlot.status === TimeSlotStatus.OPEN && timeSlot.bookedCount < timeSlot.capacity"
<text>已过期</text> class="book-button" hover-class="book-button--pressed" @tap.stop="emit('book', timeSlot)">
</view> 预约
</template> </button>
<text v-else class="unavailable-label">{{ isPast ? '已结束' : timeSlot.status === TimeSlotStatus.CLOSED ? '未开放' : '已约满' }}</text>
<!-- OPEN + not booked -->
<template v-else-if="timeSlot.status === TimeSlotStatus.OPEN && !timeSlot.isBookedByMe">
<view class="action-btn btn-book" @tap.stop="emit('book', timeSlot)">
<text>预约</text>
</view>
</template>
<!-- OPEN + booked by me -->
<template v-else-if="timeSlot.status === TimeSlotStatus.OPEN && timeSlot.isBookedByMe">
<view class="action-badge badge-booked">
<text>已预约</text>
</view>
<view class="cancel-link" @tap.stop="emit('cancel', timeSlot)">
<text>取消</text>
</view>
</template>
<!-- FULL -->
<template v-else-if="timeSlot.status === TimeSlotStatus.FULL">
<view class="action-badge badge-full">
<text>已约满</text>
</view>
</template>
<!-- CLOSED -->
<template v-else>
<view class="action-badge badge-closed">
<text>已关闭</text>
</view>
</template>
</view>
</view> </view>
</view> </view>
</view> </view>
@@ -96,7 +31,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { TimeSlotWithBookingStatus } from '@mp-pilates/shared' import type { TimeSlotWithBookingStatus } from '@mp-pilates/shared'
import { TimeSlotStatus } from '@mp-pilates/shared' import { BookingStatus, TimeSlotStatus } from '@mp-pilates/shared'
import { computed } from 'vue' import { computed } from 'vue'
import { isSlotPast } from '../utils/format' import { isSlotPast } from '../utils/format'
@@ -120,20 +55,19 @@ const durationMin = computed(() => {
return (eh * 60 + em) - (sh * 60 + sm) return (eh * 60 + em) - (sh * 60 + sm)
}) })
const capacityLabel = computed(() => { const myBookingLabel = computed(() => (
const { bookedCount, capacity, status } = props.timeSlot props.timeSlot.myBookingStatus === BookingStatus.PENDING_CONFIRMATION
if (status === TimeSlotStatus.CLOSED) return '已关闭' ? '待老师确认'
if (status === TimeSlotStatus.FULL) return '已约' : '已约'
const remaining = capacity - bookedCount ))
return `剩余${remaining}`
})
const capacityClass = computed(() => { const capacityLabel = computed(() => {
const { bookedCount, capacity, status } = props.timeSlot const { bookedCount, capacity, status, isBookedByMe } = props.timeSlot
if (status === TimeSlotStatus.CLOSED) return 'cap-closed' if (isBookedByMe) return myBookingLabel.value
if (status === TimeSlotStatus.FULL) return 'cap-full' if (isPast.value) return '已结束'
if (bookedCount >= capacity * 0.8) return 'cap-almost' if (status === TimeSlotStatus.CLOSED) return '暂未开放'
return 'cap-open' if (status === TimeSlotStatus.FULL || bookedCount >= capacity) return '名额已满'
return `还可约 ${Math.max(0, capacity - bookedCount)}`
}) })
const statusClass = computed(() => { const statusClass = computed(() => {
@@ -148,275 +82,39 @@ const isPast = computed(() => isSlotPast(props.timeSlot.date, props.timeSlot.sta
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
/* ─── Wrapper ─── */ .slot-card {
.slot-card-wrapper { margin: 0 32rpx 20rpx;
position: relative; padding: 28rpx;
margin: 0 24rpx 20rpx; border: 1rpx solid #eee8e3;
min-height: 220rpx; border-radius: 28rpx;
transition: all 0.2s ease; background: #fff;
color: #514943;
&:active {
transform: scale(0.98);
}
/* Status-based opacity */
&.status-expired {
opacity: 0.55;
}
&.status-full,
&.status-closed {
opacity: 0.75;
}
} }
.slot-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 16rpx; }
/* ─── Ticket background image ─── */ .slot-time { display: flex; align-items: baseline; gap: 12rpx; font-variant-numeric: tabular-nums; }
.ticket-bg { .time-start { font-size: 40rpx; font-weight: 500; line-height: 1.2; }
position: absolute; .time-end { font-size: 28rpx; color: #8b817b; }
top: 0; .slot-duration { flex-shrink: 0; font-size: 22rpx; color: #8b817b; }
left: 0; .slot-body { display: flex; align-items: center; justify-content: space-between; gap: 20rpx; margin-top: 24rpx; }
width: 100%; .course-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
height: 100%; .course-name { font-size: 26rpx; font-weight: 400; }
pointer-events: none; .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; }
/* ─── Content overlay ─── */ .cancel-link { padding: 12rpx 0 8rpx 20rpx; font-size: 21rpx; color: #8b817b; }
.ticket-content { .unavailable-label { padding: 14rpx 24rpx; border-radius: 999rpx; background: #f3f0ed; color: #8b817b; font-size: 23rpx; }
position: relative; .slot-card--booked { background: #f2f5f0; border-color: #e2e9df; }
z-index: 1; .slot-card--expired, .slot-card--closed, .slot-card--full {
display: flex; background: #f7f5f2;
flex-direction: column; .time-start, .course-name { color: #8b817b; }
padding: 28rpx 40rpx 24rpx; .slot-status { color: #948a82; }
}
/* ═══ Top section: Time row ═══ */
.ticket-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.time-block {
display: flex;
flex-direction: column;
align-items: flex-start;
min-width: 100rpx;
&--right {
align-items: flex-end;
}
}
.time-main {
font-size: 40rpx;
font-weight: 800;
color: #1a1a2e;
line-height: 1;
letter-spacing: 1rpx;
}
.time-label {
margin-top: 8rpx;
font-size: 22rpx;
color: #999;
font-weight: 500;
}
/* Duration center block */
.duration-block {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
padding: 0 16rpx;
}
.duration-line {
display: flex;
align-items: center;
width: 100%;
margin-top: 8rpx;
}
.line-dot {
width: 10rpx;
height: 10rpx;
border-radius: 50%;
background: #ccc;
flex-shrink: 0;
}
.line-dash {
flex: 1;
height: 2rpx;
background: repeating-linear-gradient(
to right,
#d0d0d0 0,
#d0d0d0 8rpx,
transparent 8rpx,
transparent 16rpx
);
}
.duration-icon {
flex-shrink: 0;
width: 48rpx;
height: 48rpx;
border-radius: 50%;
background: rgba($primary-color, 0.1);
display: flex;
align-items: center;
justify-content: center;
margin: 0 8rpx;
}
.icon-text {
font-size: 22rpx;
}
.duration-text {
margin-top: 6rpx;
font-size: 22rpx;
color: #999;
font-weight: 500;
}
/* Capacity tag */
.capacity-tag {
margin-top: 8rpx;
padding: 4rpx 12rpx;
border-radius: 6rpx;
font-size: 20rpx;
&.cap-open {
background: rgba(76, 175, 80, 0.08);
.capacity-text {
color: #4caf50;
}
}
&.cap-almost {
background: rgba(245, 158, 11, 0.08);
.capacity-text {
color: #f59e0b;
}
}
&.cap-full {
background: rgba(239, 68, 68, 0.08);
.capacity-text {
color: #ef4444;
}
}
&.cap-closed {
background: rgba(0, 0, 0, 0.04);
.capacity-text {
color: #999;
}
}
}
.capacity-text {
font-size: 20rpx;
font-weight: 600;
}
/* ═══ Tear-off dashed line ═══ */
.tear-line {
margin: 20rpx -40rpx 16rpx;
height: 2rpx;
background: repeating-linear-gradient(
to right,
#e0dcd6 0,
#e0dcd6 10rpx,
transparent 10rpx,
transparent 20rpx
);
opacity: 0.6;
}
/* ═══ Bottom section ═══ */
.ticket-bottom {
display: flex;
align-items: center;
justify-content: space-between;
}
.course-info {
flex: 1;
}
.course-name {
font-size: 30rpx;
font-weight: 700;
color: #1a1a2e;
letter-spacing: 1rpx;
}
/* ─── Action area ─── */
.action-area {
display: flex;
align-items: center;
gap: 12rpx;
flex-shrink: 0;
}
.action-btn,
.action-badge {
padding: 10rpx 24rpx;
border-radius: 20rpx;
font-size: 26rpx;
font-weight: 600;
white-space: nowrap;
display: flex;
align-items: center;
justify-content: center;
}
.btn-book {
background: linear-gradient(135deg, $primary-color 0%, $primary-dark 100%);
color: #fff;
box-shadow: 0 4rpx 16rpx rgba($primary-dark, 0.3);
min-width: 120rpx;
height: 60rpx;
transition: all 0.15s;
&:active {
opacity: 0.85;
transform: scale(0.96);
}
}
.badge-booked {
background: linear-gradient(135deg, $primary-selected-bg, $primary-border);
color: $primary-dark;
}
.badge-expired {
background: #f0f0f0;
color: #999;
}
.badge-full {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
.badge-closed {
background: #f0f0f0;
color: #bbb;
}
.cancel-link {
font-size: 22rpx;
color: #ef4444;
font-weight: 500;
text-decoration: underline;
text-decoration-color: rgba(239, 68, 68, 0.3);
} }
</style> </style>

View File

@@ -1,40 +1,59 @@
<template> <template>
<view class="studio-info"> <view class="studio-info">
<!-- Horizontal photo strip --> <text class="section-title">来工作室坐坐</text>
<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 + Chat row --> <!-- Address + Chat row -->
<view class="location-row"> <view class="location-row">
<view class="location-left" @tap="handleAddressTap"> <view class="location-left" @tap="handleAddressTap">
<view class="location-icon" /> <view class="location-content">
<text class="location-text"> <text class="location-label">场馆地址</text>
{{ studioInfo?.address || '深圳市宝安区西乡街道财富港 D 座 1203D' }} <text class="location-text">
</text> {{ studioInfo?.address || '深圳市宝安区西乡街道财富港 D 座 1203D' }}
</text>
</view>
</view> </view>
<button class="chat-btn" open-type="contact"> <button class="chat-btn" open-type="contact">
<view class="chat-icon" /> <text>联系老师</text>
</button> </button>
</view> </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> </view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { StudioConfig } from '@mp-pilates/shared' import { computed } from 'vue'
import {
DEFAULT_STUDIO_GALLERY_PHOTOS,
type StudioConfig,
} from '@mp-pilates/shared'
const props = defineProps<{ const props = defineProps<{
studioInfo: StudioConfig | null studioInfo: StudioConfig | null
}>() }>()
const galleryPhotos = computed(() => {
const photos = props.studioInfo?.photos?.filter(Boolean) ?? []
return photos.length ? photos : [...DEFAULT_STUDIO_GALLERY_PHOTOS]
})
function previewPhoto(index: number) { function previewPhoto(index: number) {
if (!props.studioInfo?.photos?.length) return
uni.previewImage({ uni.previewImage({
current: index, current: index,
urls: props.studioInfo.photos, urls: galleryPhotos.value,
}) })
} }
@@ -68,143 +87,15 @@ function copyAddress() {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.studio-info { .studio-info { margin: 36rpx 32rpx 0; }
background: #ffffff; .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; }
/* ── Photo strip ── */ .location-label { display: block; font-size: 21rpx; color: #8b817b; margin-bottom: 6rpx; }
.photo-strip { .location-text { font-size: 24rpx; color: #6f655e; line-height: 1.7; overflow-wrap: anywhere; }
width: 100%; .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; } }
padding: 24rpx 0; .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; }
.photo-strip-inner { .gallery-image { width: 100%; height: 100%; }
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-text {
font-size: 26rpx;
color: #666;
line-height: 1.5;
word-break: break-all;
}
/* ── Icons ── */
.location-icon {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: rgba($brand-color, 0.06);
position: relative;
flex-shrink: 0;
// 定位图标 — 圆头 + 尖尾
&::before {
content: '';
position: absolute;
top: 14rpx;
left: 50%;
transform: translateX(-50%);
width: 18rpx;
height: 18rpx;
border: 2.5rpx solid $brand-color;
border-radius: 50% 50% 50% 0;
transform: translateX(-50%) rotate(-45deg);
box-sizing: border-box;
}
// 中心白点
&::after {
content: '';
position: absolute;
top: 21rpx;
left: 50%;
transform: translateX(-50%);
width: 6rpx;
height: 6rpx;
background: $brand-color;
border-radius: 50%;
box-sizing: border-box;
}
}
.chat-btn {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: rgba($brand-color, 0.06);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 0;
margin: 0;
border: none;
}
.chat-btn::after {
border: none;
}
.chat-icon {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: rgba($brand-color, 0.06);
position: relative;
// 消息气泡
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 26rpx;
height: 20rpx;
border: 2.5rpx solid $brand-color;
border-radius: 6rpx;
box-sizing: border-box;
}
// 气泡尾巴
&::after {
content: '';
position: absolute;
bottom: 12rpx;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 5rpx solid transparent;
border-right: 5rpx solid transparent;
border-top: 7rpx solid $brand-color;
}
}
</style> </style>

View File

@@ -0,0 +1,376 @@
<template>
<view v-if="visible" class="modal-mask" @tap="handleClose">
<view class="modal-panel" @tap.stop>
<view class="modal-header">
<text class="modal-title">微信消息提醒设置</text>
<view class="close-btn" @tap="handleClose">
<text class="close-icon"></text>
</view>
</view>
<view class="notice-box">
<text class="notice-title">💡 为什么需要增加订阅次数</text>
<text class="notice-desc">
微信订阅消息每授权 1 次可接收 1 条通知建议点击下方按钮并在弹出的微信授权窗中勾选<text class="notice-highlight">总是保持以上选择</text>即可永久无感自动接收课程变动与上课提醒
</text>
</view>
<view class="quota-list">
<view v-if="loading" class="loading-wrap">
<text class="loading-text">加载提醒状态中...</text>
</view>
<view v-else-if="quotas.length === 0" class="empty-wrap">
<text class="empty-text">未检测到可用提醒模板配置</text>
</view>
<view v-else v-for="item in quotas" :key="item.scene" class="quota-item">
<view class="quota-info">
<view class="quota-title-row">
<text class="quota-icon">{{ getSceneIcon(item.scene) }}</text>
<text class="quota-name">{{ getSceneName(item.scene) }}</text>
</view>
<text class="quota-desc">{{ item.description }}</text>
</view>
<view class="quota-badge" :class="getBadgeClass(item.remainingQuota)">
<text class="badge-text">
{{ item.remainingQuota > 0 ? `${item.remainingQuota}` : '待补充' }}
</text>
</view>
</view>
</view>
<view class="modal-actions">
<button
class="btn-primary"
:loading="subscribing"
:disabled="subscribing"
@tap="handleTopUp"
>
一键补充全部提醒次数 (+3)
</button>
<view class="btn-secondary" @tap="handleOpenSettings">
<text class="btn-secondary-text">微信权限与通知设置</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { SubscriptionMessageScene } from '@mp-pilates/shared'
import type { SubscriptionQuotaItem } from '@mp-pilates/shared'
import {
fetchUserSubscriptionQuotas,
requestBookingBundleSubscriptionMessage,
openSubscribeSettings,
} from '../utils/wechat-subscription'
const props = defineProps<{
visible: boolean
}>()
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
}>()
const quotas = ref<SubscriptionQuotaItem[]>([])
const loading = ref(false)
const subscribing = ref(false)
async function loadQuotas() {
loading.value = true
try {
quotas.value = await fetchUserSubscriptionQuotas()
} finally {
loading.value = false
}
}
watch(
() => props.visible,
(val) => {
if (val) {
void loadQuotas()
}
},
{ immediate: true },
)
function handleClose() {
emit('update:visible', false)
}
function getSceneIcon(scene: SubscriptionMessageScene): string {
switch (scene) {
case SubscriptionMessageScene.BOOKING_CREATED:
return '📅'
case SubscriptionMessageScene.CLASS_REMINDER:
return '⏰'
case SubscriptionMessageScene.BOOKING_CANCELLED:
return '📋'
case SubscriptionMessageScene.CLASS_REVIEW:
return '⭐'
default:
return '🔔'
}
}
function getSceneName(scene: SubscriptionMessageScene): string {
switch (scene) {
case SubscriptionMessageScene.BOOKING_CREATED:
return '约课成功通知'
case SubscriptionMessageScene.CLASS_REMINDER:
return '上课前 1 小时提醒'
case SubscriptionMessageScene.BOOKING_CANCELLED:
return '约课取消通知'
case SubscriptionMessageScene.CLASS_REVIEW:
return '课后评价提醒'
default:
return '课程服务通知'
}
}
function getBadgeClass(quota: number): string {
if (quota >= 3) return 'badge--healthy'
if (quota > 0) return 'badge--warning'
return 'badge--danger'
}
async function handleTopUp() {
if (subscribing.value) return
subscribing.value = true
try {
const results = await requestBookingBundleSubscriptionMessage()
const acceptedCount = results.filter((r) => r.result === 'accept').length
if (acceptedCount > 0) {
uni.showToast({ title: `已成功补充 ${acceptedCount} 项提醒额度`, icon: 'success' })
await loadQuotas()
} else {
uni.showToast({ title: '未增加额度,可再次点击尝试', icon: 'none' })
}
} catch (error) {
console.warn('[subscribe] top-up failed', error)
uni.showToast({ title: '授权未完成,可进入微信设置检查', icon: 'none' })
} finally {
subscribing.value = false
}
}
async function handleOpenSettings() {
await openSubscribeSettings()
await loadQuotas()
}
</script>
<style lang="scss" scoped>
.modal-mask {
position: fixed;
inset: 0;
background: rgba(56, 48, 42, 0.45);
z-index: 1000;
display: flex;
align-items: flex-end;
justify-content: center;
}
.modal-panel {
width: 100%;
box-sizing: border-box;
background: #fbf9f6;
border-radius: 36rpx 36rpx 0 0;
padding: 32rpx 32rpx calc(32rpx + env(safe-area-inset-bottom));
max-height: 85vh;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24rpx;
}
.modal-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: 28rpx;
color: #8b817b;
}
.notice-box {
background: #f0f4ee;
border-radius: 20rpx;
padding: 20rpx 24rpx;
margin-bottom: 24rpx;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.notice-title {
font-size: 24rpx;
font-weight: 500;
color: #476d54;
}
.notice-desc {
font-size: 22rpx;
color: #657568;
line-height: 1.6;
}
.notice-highlight {
font-weight: 600;
color: #3b5a45;
}
.quota-list {
display: flex;
flex-direction: column;
gap: 16rpx;
margin-bottom: 28rpx;
max-height: 480rpx;
overflow-y: auto;
}
.loading-wrap,
.empty-wrap {
padding: 40rpx 0;
text-align: center;
}
.loading-text,
.empty-text {
font-size: 24rpx;
color: #8b817b;
}
.quota-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 24rpx;
background: #fff;
border: 2rpx solid #eee8e3;
border-radius: 20rpx;
gap: 16rpx;
}
.quota-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6rpx;
}
.quota-title-row {
display: flex;
align-items: center;
gap: 12rpx;
}
.quota-icon {
font-size: 26rpx;
}
.quota-name {
font-size: 26rpx;
font-weight: 500;
color: #514943;
}
.quota-desc {
font-size: 21rpx;
color: #8b817b;
}
.quota-badge {
padding: 6rpx 16rpx;
border-radius: 999rpx;
flex-shrink: 0;
}
.badge-text {
font-size: 22rpx;
font-weight: 500;
}
.badge--healthy {
background: #edf5eb;
.badge-text {
color: #4f7957;
}
}
.badge--warning {
background: #fdf5ea;
.badge-text {
color: #b07d39;
}
}
.badge--danger {
background: #fbeee9;
.badge-text {
color: #bc5e4c;
}
}
.modal-actions {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.btn-primary {
width: 100%;
height: 88rpx;
border-radius: 999rpx;
background: #6b8276;
color: #fff;
font-size: 28rpx;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
border: none;
line-height: 1;
&::after {
border: none;
}
&:active {
background: #597264;
}
}
.btn-secondary {
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
}
.btn-secondary-text {
font-size: 25rpx;
color: #7b8974;
}
</style>

View File

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

View File

@@ -9,6 +9,7 @@
v-for="booking in displayedBookings" v-for="booking in displayedBookings"
:key="booking.id" :key="booking.id"
class="booking-card" class="booking-card"
@tap="goToBookingDetail(booking.id)"
> >
<!-- Date column --> <!-- Date column -->
<view class="date-col"> <view class="date-col">
@@ -24,7 +25,6 @@
<view class="details-col"> <view class="details-col">
<text class="card-name">{{ booking.membership.cardType.name }}</text> <text class="card-name">{{ booking.membership.cardType.name }}</text>
<view class="time-row"> <view class="time-row">
<text class="time-icon">🕐</text>
<text class="time-text"> <text class="time-text">
{{ formatTime(booking.timeSlot.startTime) }} {{ formatTime(booking.timeSlot.endTime) }} {{ formatTime(booking.timeSlot.startTime) }} {{ formatTime(booking.timeSlot.endTime) }}
</text> </text>
@@ -72,6 +72,7 @@ function formatTime(timeStr: string): string {
function statusLabel(status: BookingStatus): string { function statusLabel(status: BookingStatus): string {
const map: Record<BookingStatus, string> = { const map: Record<BookingStatus, string> = {
[BookingStatus.PENDING_CONFIRMATION]: '待确认',
[BookingStatus.CONFIRMED]: '已确认', [BookingStatus.CONFIRMED]: '已确认',
[BookingStatus.CANCELLED]: '已取消', [BookingStatus.CANCELLED]: '已取消',
[BookingStatus.COMPLETED]: '已完成', [BookingStatus.COMPLETED]: '已完成',
@@ -81,6 +82,7 @@ function statusLabel(status: BookingStatus): string {
} }
function statusDotClass(status: BookingStatus): string { function statusDotClass(status: BookingStatus): string {
if (status === BookingStatus.PENDING_CONFIRMATION) return 'dot--pending'
if (status === BookingStatus.CONFIRMED) return 'dot--confirmed' if (status === BookingStatus.CONFIRMED) return 'dot--confirmed'
if (status === BookingStatus.COMPLETED) return 'dot--completed' if (status === BookingStatus.COMPLETED) return 'dot--completed'
if (status === BookingStatus.CANCELLED) return 'dot--cancelled' if (status === BookingStatus.CANCELLED) return 'dot--cancelled'
@@ -88,6 +90,7 @@ function statusDotClass(status: BookingStatus): string {
} }
function statusTextClass(status: BookingStatus): string { function statusTextClass(status: BookingStatus): string {
if (status === BookingStatus.PENDING_CONFIRMATION) return 'text--pending'
if (status === BookingStatus.CONFIRMED) return 'text--confirmed' if (status === BookingStatus.CONFIRMED) return 'text--confirmed'
if (status === BookingStatus.COMPLETED) return 'text--completed' if (status === BookingStatus.COMPLETED) return 'text--completed'
if (status === BookingStatus.CANCELLED) return 'text--cancelled' if (status === BookingStatus.CANCELLED) return 'text--cancelled'
@@ -97,133 +100,32 @@ function statusTextClass(status: BookingStatus): string {
function goToBookings() { function goToBookings() {
uni.navigateTo({ url: '/pages/profile/bookings' }) uni.navigateTo({ url: '/pages/profile/bookings' })
} }
function goToBookingDetail(id: string) {
uni.navigateTo({ url: `/pages/booking/detail?id=${id}` })
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.upcoming-section { .upcoming-section { margin: 36rpx 32rpx 0; }
margin: 24rpx 24rpx 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; }
.section-header { .booking-card { display: flex; align-items: center; gap: 24rpx; margin-bottom: 16rpx; padding: 26rpx; border-radius: 28rpx; background: #fff; border: 1rpx solid #eee8e3; }
display: flex; .date-col { display: flex; flex-direction: column; align-items: center; gap: 4rpx; width: 72rpx; flex-shrink: 0; }
align-items: center; .date-day { font-size: 44rpx; font-weight: 400; line-height: 1.1; color: #617d73; }
justify-content: space-between; .date-month, .date-weekday { font-size: 20rpx; color: #8b817b; }
margin-bottom: 16rpx; .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; }
.section-title { .time-row { margin-bottom: 8rpx; }
font-size: 32rpx; .time-text { font-size: 24rpx; color: #6f655e; font-variant-numeric: tabular-nums; }
font-weight: 700; .status-row { display: flex; align-items: center; gap: 8rpx; }
color: #1a1a2e; .status-dot { width: 8rpx; height: 8rpx; border-radius: 50%; background: #a5b8ab; }
} .status-text { font-size: 21rpx; color: #617d73; }
.dot--pending { background: #b8a18b; }
.section-more { .text--pending { color: #957c65; }
font-size: 26rpx; .dot--cancelled { background: #b69589; }
color: $primary-dark; .text--cancelled { color: #9c7a6e; }
} .booking-arrow { font-size: 32rpx; color: #b2a79e; flex-shrink: 0; }
.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: $primary-dark;
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;
}
</style> </style>

View File

@@ -1,15 +1,14 @@
<template> <template>
<view class="user-card"> <view class="user-card">
<!-- Header: gradient background, padded to sit below nav bar --> <view class="user-card__header">
<view class="user-card__header" :style="{ paddingTop: (navBarHeight ?? 0) + 'px' }">
<!-- Not logged in state --> <!-- Not logged in state -->
<view v-if="!loggedIn" class="user-card__guest"> <view v-if="!loggedIn" class="user-card__guest">
<view class="user-card__avatar-wrap"> <view class="user-card__avatar-wrap">
<image class="user-card__avatar-img" src="/static/default-avatar.jpg" mode="aspectFill" /> <image class="user-card__avatar-img" src="/static/default-avatar.jpg" mode="aspectFill" />
</view> </view>
<view class="user-card__guest-info"> <view class="user-card__guest-info">
<text class="user-card__guest-title">Hi欢迎来到普拉提</text> <text class="user-card__guest-title">欢迎来到工作室</text>
<text class="user-card__guest-sub">登录后查看个人数据</text> <text class="user-card__guest-sub">登录后查看会员卡与练习记录</text>
</view> </view>
<button class="user-card__login-btn" :loading="loading" @tap="handleLogin"> <button class="user-card__login-btn" :loading="loading" @tap="handleLogin">
微信登录 微信登录
@@ -17,7 +16,7 @@
</view> </view>
<!-- Logged in + profile loaded --> <!-- 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"> <view class="user-card__avatar-wrap">
<image <image
class="user-card__avatar-img" class="user-card__avatar-img"
@@ -25,17 +24,15 @@
mode="aspectFill" mode="aspectFill"
@error="onAvatarError" @error="onAvatarError"
/> />
<!-- VIP badge hidden for now -->
<!-- <view class="user-card__vip-badge" v-if="vipLevel">
<text class="user-card__vip-text">{{ vipLevel }}</text>
</view> -->
</view> </view>
<view class="user-card__info"> <view class="user-card__info">
<view class="user-card__name-row"> <view class="user-card__name-row">
<text class="user-card__nickname">{{ user!.nickname }}</text> <text class="user-card__nickname">{{ user!.nickname }}</text>
<text v-if="hasMembership" class="user-card__member-label">会员</text>
</view> </view>
<text v-if="maskedPhone" class="user-card__phone">{{ maskedPhone }}</text> <text class="user-card__phone">{{ maskedPhone || '完善个人资料' }}</text>
</view> </view>
<text class="user-card__edit">资料 </text>
</view> </view>
<!-- Logged in but profile still loading --> <!-- Logged in but profile still loading -->
@@ -52,44 +49,43 @@
</view> </view>
</view> </view>
<!-- Stats row: shown only when profile is loaded --> <button v-if="loggedIn && hasProfile" class="membership-row" @tap="handleMembershipTap">
<view v-if="loggedIn && hasProfile" class="user-card__stats"> <text class="membership-row__label">会员卡</text>
<view class="user-card__stat-item"> <text class="membership-row__value">{{ membershipLabel }}</text>
<text class="user-card__stat-value">{{ stats?.totalBookings ?? 0 }}</text> <text v-if="!membershipsError && membershipsLoaded && activeMemberships.length > 1" class="membership-row__count">{{ activeMemberships.length }} </text>
<text class="user-card__stat-label">总训练()</text> <text class="membership-row__arrow"></text>
</view> </button>
<view class="user-card__stat-divider" /> <view v-if="loggedIn && hasProfile && membershipsLoaded && !membershipsError && activeMemberships.length" class="mini-progress-list">
<view class="user-card__stat-item"> <button v-for="item in cardProgress" :key="item.id" class="mini-progress" :aria-label="`${item.name}${item.label}`" @tap="handleMembershipTap">
<text class="user-card__stat-value">{{ stats?.monthBookings ?? 0 }}</text> <view v-if="item.percent !== null" class="mini-progress__fill" :style="{ width: `${item.percent}%` }" />
<text class="user-card__stat-label">本月()</text> <view class="mini-progress__heading"><text class="mini-progress__name">{{ item.name }}</text><text class="mini-progress__usage">{{ item.label }}</text></view>
</view> </button>
<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>
</view>
</view> </view>
</view> </view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import type { UserProfileResponse, UserStatsResponse, MembershipWithCardType } from '@mp-pilates/shared' import type { UserProfileResponse, MembershipWithCardType } from '@mp-pilates/shared'
import { MembershipStatus } from '@mp-pilates/shared' import { MembershipStatus } from '@mp-pilates/shared'
import { getCardTypeLabel, getMembershipTotalTimes, getMembershipUsedTimes } from '../utils/format'
const props = defineProps<{ const props = defineProps<{
loggedIn: boolean loggedIn: boolean
hasProfile: boolean hasProfile: boolean
user: UserProfileResponse | null user: UserProfileResponse | null
stats: UserStatsResponse | null now: number
membershipsLoading?: boolean
membershipsLoaded?: boolean
membershipsError?: boolean
memberships?: readonly MembershipWithCardType[] memberships?: readonly MembershipWithCardType[]
loading?: boolean loading?: boolean
/** Height of the custom nav bar in px, so header content starts below it */
navBarHeight?: number
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'login'): void (e: 'login'): void
(e: 'edit'): void
(e: 'refresh-memberships'): void
}>() }>()
const avatarFailed = ref(false) const avatarFailed = ref(false)
@@ -117,26 +113,43 @@ const maskedPhone = computed(() => {
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
}) })
// Derive VIP level from active memberships count
const activeMemberships = computed(() => const activeMemberships = computed(() =>
props.memberships?.filter((m) => m.status === MembershipStatus.ACTIVE) ?? [], props.memberships?.filter((m) => m.status === MembershipStatus.ACTIVE) ?? [],
) )
const vipLevel = computed(() => { const activeMembershipCount = computed(
const count = activeMemberships.value.length () => props.user?.activeMembershipCount ?? activeMemberships.value.length,
if (count >= 3) return 'VIP3'
if (count >= 2) return 'VIP2'
if (count >= 1) return 'VIP1'
return null
})
// Sum remaining sessions from all active time-based memberships
const remainingSessions = computed(() =>
activeMemberships.value
.filter((m) => m.cardType.type === 'TIMES')
.reduce((sum, m) => sum + m.remainingCount, 0),
) )
const hasMembership = computed(() => activeMembershipCount.value > 0)
const membershipLabel = computed(() => {
if (props.membershipsError) return '暂未更新,点击重试'
if (!props.membershipsLoaded) return '加载中…'
if (!activeMemberships.value.length) return '暂无有效卡'
return [...new Set(activeMemberships.value.map(m => getCardTypeLabel(m.cardType.type)))].join(' · ')
})
const cardProgress = computed(() => activeMemberships.value.map(m => {
const total = getMembershipTotalTimes(m)
if (m.remainingTimes !== null) {
const used = getMembershipUsedTimes(m)
return { id: m.id, name: m.cardType.name,
label: total && total > 0 ? `已用 ${used}/${total} 次 · 余 ${m.remainingTimes}` : `剩余 ${m.remainingTimes}`,
percent: total && total > 0 ? Math.max(0, Math.min(100, used / total * 100)) : null }
}
const start = new Date(m.startDate).getTime()
const end = new Date(m.expireDate).getTime()
const remainingDays = Math.max(0, Math.ceil((end - props.now) / 86400000))
return { id: m.id, name: m.cardType.name,
label: `不限次 · 有效期剩 ${remainingDays}`,
percent: end > start ? Math.max(0, Math.min(100, (end - props.now) / (end - start) * 100)) : null }
}))
function handleMembershipTap() {
if (props.membershipsError) { emit('refresh-memberships'); return }
uni.navigateTo({ url: '/pages/profile/membership' })
}
function onAvatarError() { function onAvatarError() {
avatarFailed.value = true avatarFailed.value = true
} }
@@ -148,186 +161,34 @@ function handleLogin() {
<style lang="scss" scoped> <style lang="scss" scoped>
.user-card { .user-card {
background: linear-gradient(160deg, #E1F4FA 0%, $primary-color 50%, $primary-dark 100%); margin: 20rpx 32rpx 0; padding: 28rpx; background: #f2e9e3; border-radius: 32rpx;
border-radius: 0 0 40rpx 40rpx; &__user, &__loading { display: flex; align-items: center; gap: 20rpx; }
overflow: hidden; &__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; }
&__header { &__info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10rpx; }
padding: $spacing-lg $spacing-lg $spacing-lg; &__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; }
// ── Guest state ── &__phone { font-size: 24rpx; color: #8b7b70; }
&__guest { &__edit { flex-shrink: 0; font-size: 22rpx; color: #8b7b70; padding: 16rpx 0 16rpx 8rpx; }
display: flex; &__guest { display: flex; align-items: center; flex-wrap: wrap; gap: 20rpx; }
align-items: center; &__guest-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10rpx; }
gap: $spacing-md; &__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; } }
&__guest-info { &__avatar-skeleton { width: 108rpx; height: 108rpx; border-radius: 50%; background: #e6d9ce; }
flex: 1; &__nickname-skeleton { width: 150rpx; height: 32rpx; border-radius: 8rpx; background: #e6d9ce; }
display: flex; &__phone-skeleton { width: 180rpx; height: 22rpx; border-radius: 6rpx; background: #e6d9ce; }
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;
}
} }
.membership-row { display: flex; align-items: center; gap: 12rpx; width: 100%; margin: 20rpx 0 0; padding: 18rpx 0 0; min-height: 62rpx; border-radius: 0; border-top: 1rpx solid #e3d7ce; background: transparent; text-align: left; line-height: 1.5; font-size: 23rpx; color: #796759; &::after { border: none; } }
.membership-row__label { flex-shrink: 0; color: #8b7b70; }
.membership-row__value { flex: 1; min-width: 0; text-align: right; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.membership-row__count { flex-shrink: 0; font-size: 21rpx; color: #8b7b70; }
.membership-row__arrow { flex-shrink: 0; font-size: 28rpx; color: #8b7b70; }
.mini-progress-list { margin-top: 12rpx; display: flex; flex-direction: column; gap: 8rpx; }
.mini-progress { position: relative; display: block; width: 100%; height: 48rpx; margin: 0; padding: 0 16rpx; overflow: hidden; border: 1rpx solid #d6d9ca; border-radius: 10rpx; background: #eeece4; text-align: left; line-height: 46rpx; &::after { border: none; } &:active { opacity: .8; } }
.mini-progress__heading { position: relative; z-index: 1; display: flex; align-items: center; gap: 12rpx; height: 100%; font-size: 20rpx; color: #46513f; }
.mini-progress__name { flex: 1; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.mini-progress__usage { flex-shrink: 0; font-variant-numeric: tabular-nums; }
.mini-progress__fill { position: absolute; inset: 0 auto 0 0; background: linear-gradient(90deg, #dce3d3, #c9d7bf); border-right: 1rpx solid rgba(109, 135, 91, .16); }
</style> </style>

View File

@@ -1,18 +1,22 @@
{ {
"name": "普拉提约课", "name": "普拉提约课",
"appid": "", "appid": "wx3e7a133d2305fa2c",
"description": "普拉提工作室约课小程序", "description": "普拉提工作室约课小程序",
"versionName": "0.1.0", "versionName": "0.1.0",
"versionCode": "100", "versionCode": "100",
"transformPx": false, "transformPx": false,
"mp-weixin": { "mp-weixin": {
"appid": "", "appid": "wx3e7a133d2305fa2c",
"setting": { "setting": {
"urlCheck": false, "urlCheck": false,
"es6": true, "es6": true,
"minified": true "minified": true
}, },
"usingComponents": true, "usingComponents": true,
"lazyCodeLoading": "requiredComponents",
"optimization": {
"subPackages": true
},
"permission": { "permission": {
"scope.userLocation": { "scope.userLocation": {
"desc": "用于获取工作室位置导航" "desc": "用于获取工作室位置导航"

View File

@@ -12,13 +12,19 @@
{ {
"path": "pages/booking/index", "path": "pages/booking/index",
"style": { "style": {
"navigationStyle": "custom" "navigationStyle": "custom",
"componentPlaceholder": {
"booking-confirm-popup": "view"
}
} }
}, },
{ {
"path": "pages/booking/detail", "path": "pages/booking/detail",
"style": { "style": {
"navigationStyle": "custom" "navigationStyle": "custom",
"componentPlaceholder": {
"booking-confirm-popup": "view"
}
} }
}, },
{ {
@@ -45,6 +51,12 @@
"navigationStyle": "custom" "navigationStyle": "custom"
} }
}, },
{
"path": "pages/profile/teaching-schedule",
"style": {
"navigationStyle": "custom"
}
},
{ {
"path": "pages/profile/info", "path": "pages/profile/info",
"style": { "style": {
@@ -52,58 +64,191 @@
} }
}, },
{ {
"path": "pages/admin/index", "path": "pages/profile/invite",
"style": { "style": {
"navigationStyle": "custom" "navigationStyle": "custom"
} }
}, },
{ {
"path": "pages/admin/bookings", "path": "pages/teacher/detail",
"style": { "style": {
"navigationStyle": "custom" "navigationStyle": "custom"
} }
}, },
{ {
"path": "pages/admin/schedule", "path": "pages/profile/progress",
"style": { "style": {
"navigationStyle": "custom" "navigationStyle": "custom"
} }
}
],
"subPackages": [
{
"root": "pages/admin",
"pages": [
{
"path": "index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "analytics",
"style": {
"navigationStyle": "custom",
"enablePullDownRefresh": true
}
},
{
"path": "bookings",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "schedule",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "slot-adjust",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "members",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "member-detail",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "member-edit",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "member-supplement",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "member-arrange",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "orders",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "card-types",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "studio",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "member-progress",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "reviews",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "portrait-today",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "portrait-leads",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "portrait-lead-detail",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "portrait-assessment",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "portrait-plan",
"style": {
"navigationStyle": "custom"
}
}
]
}, },
{ {
"path": "pages/admin/week-template", "root": "pages/portrait",
"style": { "pages": [
"navigationStyle": "custom" {
} "path": "index",
}, "style": {
{ "navigationStyle": "custom"
"path": "pages/admin/slot-adjust", }
"style": { },
"navigationStyle": "custom" {
} "path": "assessment",
}, "style": {
{ "navigationStyle": "custom"
"path": "pages/admin/members", }
"style": { },
"navigationStyle": "custom" {
} "path": "report",
}, "style": {
{ "navigationStyle": "custom"
"path": "pages/admin/orders", }
"style": { },
"navigationStyle": "custom" {
} "path": "advice",
}, "style": {
{ "navigationStyle": "custom"
"path": "pages/admin/card-types", }
"style": { },
"navigationStyle": "custom" {
} "path": "trial",
}, "style": {
{ "navigationStyle": "custom"
"path": "pages/admin/studio", }
"style": { },
"navigationStyle": "custom" {
} "path": "plan",
"style": {
"navigationStyle": "custom"
}
}
]
} }
], ],
"globalStyle": { "globalStyle": {

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>

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -2,157 +2,83 @@
<view class="page" :style="{ paddingTop: navBarHeight }"> <view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="管理中心" show-back /> <CustomNavBar title="管理中心" show-back />
<!-- Stats summary card --> <!-- Section: 课务运营 -->
<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>
</view>
<!-- Section header: 课程管理 -->
<view class="section-header"> <view class="section-header">
<text class="section-title">课程管理</text> <text class="section-title">今日经营</text>
</view> </view>
<!-- List: schedule -->
<view class="list"> <view class="list">
<view class="list-item" @tap="navigate('/pages/admin/bookings')"> <view class="list-item" @tap="navigate('/pages/admin/portrait-today')">
<view class="item-left"> <text class="item-title">今日经营助手</text>
<view class="item-icon-wrap icon--bookings"> <text class="arrow-text"></text>
<text class="item-icon-text"></text>
</view>
<view class="item-text-group">
<text class="item-title">预约管理</text>
<text class="item-desc">查看/确认/核销学员预约</text>
</view>
</view>
<view class="item-arrow">
<text class="arrow-text"></text>
</view>
</view> </view>
<view class="list-item" @tap="navigate('/pages/admin/portrait-leads')">
<view class="list-item" @tap="navigate('/pages/admin/schedule')"> <text class="item-title">身体画像线索</text>
<view class="item-left"> <text class="arrow-text"></text>
<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 class="list-item" @tap="navigate('/pages/admin/week-template')">
<view class="item-left">
<view class="item-icon-wrap icon--template">
<text class="item-icon-text"></text>
</view>
<view class="item-text-group">
<text class="item-title">排课模板</text>
<text class="item-desc">设置每周课程模板</text>
</view>
</view>
<view class="item-arrow">
<text class="arrow-text"></text>
</view>
</view> </view>
</view> </view>
<!-- Section header: 会员与订单 --> <!-- Section: 课务运营 -->
<view class="section-header">
<text class="section-title">课务运营</text>
</view>
<view class="list">
<view class="list-item" @tap="navigate('/pages/admin/schedule')">
<text class="item-title">排课管理</text>
<text class="arrow-text"></text>
</view>
<view class="list-item" @tap="navigate('/pages/admin/bookings')">
<text class="item-title">预约管理</text>
<text class="arrow-text"></text>
</view>
</view>
<!-- Section: 教学复盘 -->
<view class="section-header">
<text class="section-title">教学复盘</text>
</view>
<view class="list">
<view class="list-item" @tap="navigate('/pages/admin/analytics')">
<text class="item-title">统计分析</text>
<text class="arrow-text"></text>
</view>
<view class="list-item" @tap="navigate('/pages/admin/reviews')">
<text class="item-title">课后评价</text>
<text class="arrow-text"></text>
</view>
</view>
<!-- Section: 会员与订单 -->
<view class="section-header"> <view class="section-header">
<text class="section-title">会员与订单</text> <text class="section-title">会员与订单</text>
</view> </view>
<!-- List: members & orders -->
<view class="list"> <view class="list">
<view class="list-item" @tap="navigate('/pages/admin/members')"> <view class="list-item" @tap="navigate('/pages/admin/members')">
<view class="item-left"> <text class="item-title">会员管理</text>
<view class="item-icon-wrap icon--members"> <text class="arrow-text"></text>
<text class="item-icon-text"></text>
</view>
<view class="item-text-group">
<text class="item-title">会员管理</text>
<text class="item-desc">查看所有会员信息</text>
</view>
</view>
<view class="item-arrow">
<text class="arrow-text"></text>
</view>
</view> </view>
<view class="list-item" @tap="navigate('/pages/admin/orders')"> <view class="list-item" @tap="navigate('/pages/admin/orders')">
<view class="item-left"> <text class="item-title">订单管理</text>
<view class="item-icon-wrap icon--orders"> <text class="arrow-text"></text>
<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>
</view> </view>
<!-- Section header: 系统 --> <!-- Section: 系统设置 -->
<view class="section-header"> <view class="section-header">
<text class="section-title">系统</text> <text class="section-title">系统设置</text>
</view> </view>
<!-- List: settings -->
<view class="list"> <view class="list">
<view class="list-item" @tap="navigate('/pages/admin/card-types')">
<text class="item-title">卡种管理</text>
<text class="arrow-text"></text>
</view>
<view class="list-item" @tap="navigate('/pages/admin/studio')"> <view class="list-item" @tap="navigate('/pages/admin/studio')">
<view class="item-left"> <text class="item-title">工作室设置</text>
<view class="item-icon-wrap icon--studio"> <text class="arrow-text"></text>
<text class="item-icon-text"></text> </view>
</view> <view class="list-item" @tap="handleIncreaseSubscriptionCount">
<view class="item-text-group"> <text class="item-title">增加订阅次数</text>
<text class="item-title">工作室设置</text> <text class="item-extra">剩余 {{ user?.adminBookingSubscriptionCount ?? 0 }} </text>
<text class="item-desc">工作室信息与配置</text> <text class="arrow-text">{{ adminSubscribeLoading ? '...' : '' }}</text>
</view>
</view>
<view class="item-arrow">
<text class="arrow-text"></text>
</view>
</view> </view>
</view> </view>
@@ -162,106 +88,60 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import CustomNavBar from '../../components/CustomNavBar.vue' import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system' import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from '../../stores/admin' import { 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 navBarHeight = ref('64px')
const adminStore = useAdminStore() const userStore = useUserStore()
const { user } = storeToRefs(userStore)
const statsLoading = ref(false) const adminSubscribeLoading = ref(false)
const stats = ref<AdminStats>({ todayBookings: 0, totalOrders: 0, totalBookings: 0 })
function navigate(path: string) { function navigate(path: string) {
uni.navigateTo({ url: path }) uni.navigateTo({ url: path })
} }
async function loadStats() { async function handleIncreaseSubscriptionCount() {
statsLoading.value = true if (adminSubscribeLoading.value) {
return
}
adminSubscribeLoading.value = true
try { try {
stats.value = await adminStore.fetchDashboardStats() const profile = await requestAdminBookingSubscriptionCount()
} catch { if (!profile) {
// fail silently — stats are non-critical 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 { } finally {
statsLoading.value = false adminSubscribeLoading.value = false
} }
} }
onMounted(() => { onMounted(() => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px` navBarHeight.value = `${getSystemLayout().navBarHeight}px`
loadStats() userStore.fetchProfile()
}) })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
/* ── Page ───────────────────────────────────── */ /* ── Page ───────────────────────── */
.page { .page {
min-height: 100vh; min-height: 100vh;
padding-bottom: 40rpx; padding-bottom: 40rpx;
} }
/* ── Stats card ─────────────────────────────── */ /* ── Section header ───────────────── */
.stats-card-wrap {
padding: 24rpx 24rpx 8rpx;
}
.stats-card {
background: #FFFFFF;
border-radius: 20rpx;
padding: 32rpx 24rpx;
display: flex;
align-items: center;
box-shadow: 0 4rpx 20rpx rgba(180, 160, 130, 0.10);
border: 1rpx solid rgba(180, 160, 130, 0.12);
}
.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, $primary-border 25%, $primary-light 50%, $primary-border 75%);
background-size: 400% 100%;
animation: shimmer 1.6s ease infinite;
}
.stat-block {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
}
.stat-num {
font-size: 44rpx;
font-weight: 700;
color: #4A4035;
line-height: 1;
font-family: 'DIN Alternate', 'Helvetica Neue', Arial, sans-serif;
}
.stat-sub {
font-size: 22rpx;
color: #A09080;
letter-spacing: 0.5rpx;
}
.stat-sep {
width: 1rpx;
height: 56rpx;
background: rgba(180, 160, 130, 0.2);
}
/* ── Section header ─────────────────────────── */
.section-header { .section-header {
padding: 32rpx 24rpx 12rpx; padding: 32rpx 24rpx 12rpx;
} }
@@ -274,7 +154,7 @@ onMounted(() => {
text-transform: uppercase; text-transform: uppercase;
} }
/* ── List ───────────────────────────────────── */ /* ── List ───────────────────────── */
.list { .list {
background: #FFFFFF; background: #FFFFFF;
margin: 0 24rpx; margin: 0 24rpx;
@@ -287,8 +167,8 @@ onMounted(() => {
.list-item { .list-item {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; gap: 16rpx;
padding: 28rpx 24rpx; padding: 32rpx 28rpx;
border-bottom: 1rpx solid rgba(180, 160, 130, 0.1); border-bottom: 1rpx solid rgba(180, 160, 130, 0.1);
transition: background 0.15s ease; transition: background 0.15s ease;
@@ -301,62 +181,23 @@ onMounted(() => {
} }
} }
.item-left {
display: flex;
align-items: center;
gap: 20rpx;
}
.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--studio { background: linear-gradient(135deg, #9E9E7E, #8E8E6E); }
.item-text-group {
display: flex;
flex-direction: column;
gap: 4rpx;
}
.item-title { .item-title {
flex: 1;
font-size: 30rpx; font-size: 30rpx;
font-weight: 600; font-weight: 500;
color: #4A4035; color: #4A4035;
letter-spacing: 0.5rpx; letter-spacing: 0.5rpx;
} }
.item-desc { .item-extra {
font-size: 24rpx; font-size: 24rpx;
color: #A09080; color: #A09080;
}
.item-arrow {
flex-shrink: 0; flex-shrink: 0;
padding-left: 16rpx;
} }
.arrow-text { .arrow-text {
font-size: 40rpx; flex-shrink: 0;
font-size: 36rpx;
color: rgba(180, 160, 130, 0.5); color: rgba(180, 160, 130, 0.5);
font-weight: 300; font-weight: 300;
line-height: 1; line-height: 1;

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,419 @@
<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="member-tabs"><button :class="{ selected: activeTab === 'overview' }" @tap="activeTab = 'overview'">会员概览</button><button :class="{ selected: activeTab === 'progress' }" @tap="activeTab = 'progress'">成长档案</button><button @tap="goReviews">课后评价 </button></view>
<MemberProgress v-if="activeTab === 'progress'" admin :user-id="userId" :refresh-key="progressRefreshKey" />
<template v-if="activeTab === 'overview'">
<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>
</template>
<view v-if="activeTab === 'overview'" 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 MemberProgress from '../../components/MemberProgress.vue'
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 activeTab = ref('overview'), progressRefreshKey = ref(0)
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 goReviews() { uni.navigateTo({ url: `/pages/admin/reviews?userId=${userId.value}` }) }
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(() => {
progressRefreshKey.value++
if (userId.value) {
loadDetail()
}
})
</script>
<style lang="scss" scoped>
.member-tabs {display:flex;margin:28rpx 32rpx 0;border-bottom:1rpx solid #e5dfd6;gap:8rpx;}
.member-tabs button{flex:1;margin:0;padding:0;background:transparent;border-radius:0;line-height:88rpx;font-size:25rpx;color:#8b817b;border-bottom:4rpx solid transparent;&::after{border:0;}&.selected{border-color:#617d73;color:#617d73;}}
.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,12 @@
<template><view class="page" :style="{ paddingTop: navBarHeight + 'px' }"><CustomNavBar title="成长档案" show-back /><MemberProgress admin :user-id="userId" :booking-id="bookingId" /></view></template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import MemberProgress from '../../components/MemberProgress.vue'
import { getSystemLayout } from '../../utils/system'
const navBarHeight = getSystemLayout().navBarHeight
const userId = ref(''), bookingId = ref('')
onLoad(query => { userId.value = String(query?.userId || ''); bookingId.value = String(query?.bookingId || '') })
</script>
<style scoped>.page{min-height:100vh;background:#fbf9f6;box-sizing:border-box;}</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>

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,81 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="专业评估" show-back />
<view class="card" v-for="field in fields" :key="field.key">
<text class="name">{{ field.label }} {{ form.observations[field.key] }}/5</text>
<slider :min="1" :max="5" :value="form.observations[field.key]" @change="onObserve(field.key, $event)" />
</view>
<view class="card">
<text class="name">主观紧张 0-10</text>
<slider :min="0" :max="10" :value="form.subjectiveTension || 0" @change="onTension" />
</view>
<view class="card">
<textarea v-model="form.coachSummary" placeholder="教练观察" class="area" />
<textarea v-model="form.trainingFocus" placeholder="训练重点" class="area" />
<textarea v-model="form.phaseGoal" placeholder="第一阶段目标" class="area" />
</view>
<view class="cta" @tap="save">保存评估报告</view>
</view>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from './stores/admin'
import { getErrorMessage } from '../../utils/auth'
import { ProfessionalAssessmentKind } from '@mp-pilates/shared'
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
const admin = useAdminStore()
const userId = ref('')
const form = reactive({
kind: ProfessionalAssessmentKind.INITIAL,
recordedAt: new Date().toISOString().slice(0, 10),
observations: { headPosition: 3, shoulderPosition: 3, thoracicExtension: 3, shoulderFlexion: 3, breathing: 3, pelvis: 3, coreControl: 3, hipMobility: 3, singleLeg: 3 },
subjectiveTension: 5,
coachSummary: '',
trainingFocus: '',
phaseGoal: '',
})
const fields = [
{ key: 'headPosition', label: '头部位置' },
{ key: 'shoulderPosition', label: '肩部位置' },
{ key: 'thoracicExtension', label: '胸椎活动' },
{ key: 'shoulderFlexion', label: '肩屈活动' },
{ key: 'breathing', label: '呼吸模式' },
{ key: 'pelvis', label: '骨盆位置' },
{ key: 'coreControl', label: '核心控制' },
{ key: 'hipMobility', label: '髋部活动' },
{ key: 'singleLeg', label: '单腿稳定' },
] as const
onLoad((query) => { userId.value = String(query?.userId || '') })
function onObserve(key: keyof typeof form.observations, event: { detail: { value: number } }) {
form.observations[key] = Number(event.detail.value)
}
function onTension(event: { detail: { value: number } }) {
form.subjectiveTension = Number(event.detail.value)
}
async function save() {
try {
await admin.createProfessionalAssessment(userId.value, form)
uni.showToast({ title: '已保存', icon: 'success' })
setTimeout(() => uni.navigateBack(), 400)
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '保存失败'), icon: 'none' })
}
}
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #fbf9f6; padding: 0 32rpx 80rpx; }
.card { background: #fff; border-radius: 20rpx; padding: 24rpx; margin-top: 16rpx; }
.name { display: block; font-size: 26rpx; color: #4a4035; margin-bottom: 8rpx; }
.area { width: 100%; min-height: 120rpx; font-size: 26rpx; margin-top: 12rpx; }
.cta { margin-top: 32rpx; height: 88rpx; border-radius: 999rpx; background: #6b8276; color: #fff; display: flex; align-items: center; justify-content: center; }
</style>

View File

@@ -0,0 +1,67 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="线索详情" show-back />
<view v-if="lead" class="card">
<text class="name">{{ lead.nickname }} · {{ lead.phone || '未留手机' }}</text>
<text class="detail">来源 {{ lead.source || 'organic' }} · {{ lead.campaignId || '无活动' }}</text>
<text class="detail">画像 {{ lead.report?.headline || '尚未生成' }}</text>
</view>
<view v-if="lead?.report" class="card">
<text class="name">五维关注度</text>
<text class="detail">肩颈 {{ lead.report.scores.cervicalShoulder }} · 脊柱 {{ lead.report.scores.spinalMobility }} · 核心 {{ lead.report.scores.coreControl }} · 髋骨盆 {{ lead.report.scores.hipPelvis }} · 下肢 {{ lead.report.scores.lowerLimb }}</text>
</view>
<view v-if="lead?.safetyFlagged" class="card"><text class="name">安全分流</text><text class="detail">不要强推体验课先确认运动条件</text></view>
<view class="card">
<text class="name">首次评估建议</text>
<text v-for="hint in lead?.firstAssessmentHints || []" :key="hint" class="detail"> {{ hint }}</text>
</view>
<view class="card">
<text class="name">跟进文案</text>
<text class="draft">{{ lead?.followUpDraft }}</text>
<view class="cta" @tap="copy">复制 微信发送</view>
</view>
<view class="cta ghost" @tap="goAssess">记录线下评估</view>
<view class="cta ghost" @tap="goPlan">生成 12 周计划</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from './stores/admin'
import type { GrowthLeadDetail } from '@mp-pilates/shared'
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
const admin = useAdminStore()
const lead = ref<GrowthLeadDetail | null>(null)
const leadId = ref('')
onLoad(async (query) => {
leadId.value = String(query?.id || '')
lead.value = await admin.fetchGrowthLead(leadId.value)
})
function copy() {
if (!lead.value) return
uni.setClipboardData({ data: lead.value.followUpDraft })
}
function goAssess() {
if (!lead.value) return
uni.navigateTo({ url: `/pages/admin/portrait-assessment?userId=${lead.value.userId}` })
}
function goPlan() {
if (!lead.value) return
uni.navigateTo({ url: `/pages/admin/portrait-plan?userId=${lead.value.userId}` })
}
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #fbf9f6; padding: 0 32rpx 80rpx; }
.card { background: #fff; border-radius: 20rpx; padding: 28rpx; margin-top: 16rpx; }
.name { display: block; font-size: 30rpx; color: #4a4035; }
.detail, .draft { display: block; margin-top: 10rpx; color: #7a6a5a; font-size: 24rpx; line-height: 1.7; }
.cta { margin-top: 20rpx; height: 84rpx; border-radius: 999rpx; background: #6b8276; color: #fff; display: flex; align-items: center; justify-content: center; }
.ghost { background: #efe8e1; color: #5d5148; }
</style>

View File

@@ -0,0 +1,41 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="身体画像线索" show-back />
<view v-for="item in items" :key="item.id" class="card" @tap="open(item.id)">
<text class="name">{{ item.nickname }} · {{ stageLabel(item.stage) }}</text>
<text class="detail">{{ item.phone || '未留手机号' }} · {{ item.source || 'organic' }}</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from './stores/admin'
import { GROWTH_LEAD_STAGE_LABELS, type GrowthLeadStage, type GrowthLeadSummary } from '@mp-pilates/shared'
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
const admin = useAdminStore()
const items = ref<GrowthLeadSummary[]>([])
onShow(async () => {
const result = await admin.fetchGrowthLeads()
items.value = [...result.items]
})
function stageLabel(stage: GrowthLeadStage) {
return GROWTH_LEAD_STAGE_LABELS[stage] || stage
}
function open(id: string) {
uni.navigateTo({ url: `/pages/admin/portrait-lead-detail?id=${id}` })
}
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #fbf9f6; padding: 0 32rpx 40rpx; }
.card { background: #fff; border-radius: 20rpx; padding: 28rpx; margin-top: 16rpx; }
.name { display: block; font-size: 30rpx; color: #4a4035; }
.detail { display: block; margin-top: 8rpx; color: #7a6a5a; font-size: 24rpx; }
</style>

View File

@@ -0,0 +1,46 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="12 周改善计划" show-back />
<text class="lead">生成后学员可在小程序看到三阶段计划而不是 12 节课</text>
<view class="cta" @tap="create">生成计划</view>
<view v-if="plan" class="card">
<text class="name">{{ plan.title }}</text>
<text v-for="phase in plan.phases" :key="phase.id" class="detail">{{ phase.name }} · {{ phase.lessonStart }}-{{ phase.lessonEnd }} </text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from './stores/admin'
import { getErrorMessage } from '../../utils/auth'
import type { TrainingPlanRecord } from '@mp-pilates/shared'
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
const admin = useAdminStore()
const userId = ref('')
const plan = ref<TrainingPlanRecord | null>(null)
onLoad((query) => { userId.value = String(query?.userId || '') })
async function create() {
try {
plan.value = await admin.createTrainingPlan(userId.value, { title: '你的 12 周身体改善计划', weeks: 12 })
uni.showToast({ title: '已生成', icon: 'success' })
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '生成失败'), icon: 'none' })
}
}
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #fbf9f6; padding: 0 32rpx 60rpx; }
.lead { display: block; padding: 24rpx 8rpx; color: #7a6a5a; font-size: 26rpx; line-height: 1.6; }
.cta { height: 88rpx; border-radius: 999rpx; background: #6b8276; color: #fff; display: flex; align-items: center; justify-content: center; }
.card { margin-top: 24rpx; background: #fff; border-radius: 20rpx; padding: 28rpx; }
.name { display: block; font-size: 30rpx; color: #4a4035; }
.detail { display: block; margin-top: 10rpx; color: #7a6a5a; font-size: 24rpx; }
</style>

View File

@@ -0,0 +1,55 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="今日经营助手" show-back />
<view class="hero">
<text class="title">今天有 {{ dashboard.tasks.length }} 件值得关注的事</text>
</view>
<view v-for="task in dashboard.tasks" :key="task.leadId + task.kind" class="card" @tap="handleTask(task)">
<text class="name">{{ task.title }}</text>
<text class="detail">{{ task.detail }}</text>
</view>
<view class="card">
<text class="name">渠道转化</text>
<text v-for="row in dashboard.funnel" :key="row.source" class="detail">
{{ row.source }} · 测评 {{ row.completed }} · 预约 {{ row.booked }} · 到店 {{ row.attended }} · 成交 {{ row.purchased }}
</text>
</view>
<view class="link" @tap="navigate('/pages/admin/portrait-leads')">全部线索 </view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from './stores/admin'
import type { GrowthTodayDashboard, GrowthTodayTask } from '@mp-pilates/shared'
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
const admin = useAdminStore()
const dashboard = ref<GrowthTodayDashboard>({ tasks: [], funnel: [] })
onShow(async () => {
dashboard.value = await admin.fetchGrowthToday()
})
function handleTask(task: GrowthTodayTask) {
if (task.kind === 'reassessment_due') {
uni.navigateTo({ url: `/pages/admin/portrait-assessment?userId=${task.userId}` })
} else if (task.leadId && task.leadId.length >= 20) {
uni.navigateTo({ url: `/pages/admin/portrait-lead-detail?id=${task.leadId}` })
}
}
function navigate(path: string) { uni.navigateTo({ url: path }) }
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #fbf9f6; padding: 0 32rpx 60rpx; }
.hero { padding: 24rpx 8rpx; }
.title { font-size: 36rpx; color: #4a4035; }
.card { background: #fff; border-radius: 20rpx; padding: 28rpx; margin-bottom: 16rpx; }
.name { display: block; font-size: 30rpx; color: #4a4035; }
.detail { display: block; margin-top: 10rpx; color: #7a6a5a; font-size: 24rpx; line-height: 1.6; }
.link { padding: 24rpx 8rpx; color: #6b8276; }
</style>

View File

@@ -0,0 +1,53 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight + 'px' }">
<CustomNavBar :title="userId ? '学员评价历史' : '课后评价'" show-back />
<view class="content">
<text class="kicker">LISTEN & GROW</text><text class="title">听见每一次练习</text><text class="intro">真实的感受是下一次更好教学的起点</text>
<view v-if="error" class="state">{{ error }}<button @tap="reload">重新加载</button></view>
<template v-else>
<template v-if="!userId && trend.length">
<picker mode="date" fields="month" :value="month" :end="today" @change="changeMonth($event.detail.value)"><view class="month-picker">{{ month }} 教学反馈 <text>切换月份 </text></view></picker>
<view class="summary"><view><text class="number">{{ current?.average ?? '—' }}</text><text>星级均分 / 5</text><text class="muted">{{ current?.count || 0 }} 份评价</text></view><view><text class="number">{{ current?.nps ?? '—' }}</text><text>净推荐值 NPS</text><text class="muted">{{ current?.npsCount || 0 }} 份推荐评分</text></view></view>
<view class="panel"><text class="section-title">近六个月 · 星级均分</text><view class="chart"><view v-for="item in trend" :key="item.month" class="column"><text>{{ item.average ?? '—' }}</text><view class="track"><view class="bar" :style="{ height: ((item.average || 0) / 5 * 100) + '%' }" /></view><text class="muted">{{ item.month.slice(5) }} </text></view></view></view>
<view class="panel"><text class="section-title">推荐意愿趋势</text><view v-for="item in trend" :key="item.month" class="trend-row"><text>{{ item.month }}</text><text>{{ item.nps === null ? '暂无样本' : 'NPS ' + item.nps }}</text><text class="muted">{{ item.npsCount }} 人</text></view><text class="footnote">推荐意愿 910 分为推荐者06 分为贬损者NPS = 推荐者占比 贬损者占比仅统计填写推荐分的评价星级不参与 NPS</text></view>
</template>
<view class="list-heading"><text>评价原声</text><text class="muted">{{ total }} · 按提交时间</text></view>
<view v-if="!rows.length && !loading" class="state">还没有评价<text class="intro">学员完成课程后可在预约详情留下反馈</text></view>
<view v-for="row in rows" :key="row.id" class="panel">
<view class="list-heading"><button class="member" @tap="openMember(row.booking.userId)">{{ row.booking.user.nickname || '学员' }} </button><text class="stars">{{ '★'.repeat(row.rating) }}{{ '☆'.repeat(5 - row.rating) }}</text></view>
<text class="lesson">{{ formatChinaDate(row.booking.timeSlot.date) }} · {{ row.booking.timeSlot.startTime }}{{ row.booking.timeSlot.endTime }}</text>
<view class="tags"><text v-for="tag in row.tags" :key="tag">{{ tag }}</text></view><text class="comment">{{ row.comment || '这位学员留下了星级评分' }}</text>
<view class="list-heading"><text class="muted">{{ formatChinaDate(row.createdAt) }} 提交</text><text v-if="row.recommendation !== null" class="muted">推荐意愿 {{ row.recommendation }} / 10</text></view>
</view>
<view v-if="loading" class="state">正在读取反馈…</view><button v-else-if="rows.length < total" class="more" @tap="loadMore">加载更多</button>
</template>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import type { ReviewEntry, ReviewSummary } from '@mp-pilates/shared'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { formatChinaDate } from '../../utils/format'
import { useAdminStore } from './stores/admin'
const store = useAdminStore(), navBarHeight = getSystemLayout().navBarHeight
const today = new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10), month = ref(today.slice(0, 7)), userId = ref('')
const rows = ref<ReviewEntry[]>([]), trend = ref<ReviewSummary[]>([]), total = ref(0), page = ref(0), loading = ref(false), error = ref('')
const current = computed(() => trend.value[trend.value.length - 1])
let sequence = 0
async function reload() {
const seq = ++sequence; loading.value = true; error.value = ''
try { const [list, summary] = await Promise.all([store.fetchReviews(userId.value, 1), userId.value ? Promise.resolve([]) : store.fetchReviewTrend(month.value)]); if (seq !== sequence) return; rows.value = list.data; total.value = list.total; page.value = 1; trend.value = summary }
catch (e) { if (seq === sequence) error.value = e instanceof Error ? e.message : '加载失败' } finally { if (seq === sequence) loading.value = false }
}
async function loadMore() { if (loading.value) return; loading.value = true; const seq = sequence; try { const list = await store.fetchReviews(userId.value, page.value + 1); if (seq !== sequence) return; rows.value = [...rows.value, ...list.data]; total.value = list.total; page.value++ } catch { uni.showToast({ title: '加载失败,请重试', icon: 'none' }) } finally { if (seq === sequence) loading.value = false } }
function changeMonth(value: string) { month.value = value.slice(0, 7); void reload() }
function openMember(id: string) { uni.navigateTo({ url: `/pages/admin/member-detail?userId=${id}` }) }
onLoad(query => { userId.value = String(query?.userId || '') })
onShow(() => { void reload() })
</script>
<style lang="scss" scoped>
.page{min-height:100vh;box-sizing:border-box;background:#fbf9f6;color:#514943;}.content{padding:36rpx 32rpx 80rpx;}.kicker{display:block;font-size:21rpx;letter-spacing:4rpx;color:#8b806e;}.title{display:block;font-family:'Songti SC','STSong',serif;font-size:44rpx;margin:22rpx 0 14rpx;}.intro{display:block;font-size:25rpx;color:#8b8174;line-height:1.8;margin-bottom:30rpx;}.month-picker{display:flex;justify-content:space-between;align-items:center;min-height:88rpx;font-size:28rpx;}.month-picker text{font-size:23rpx;color:#728065;}.summary{display:flex;padding:34rpx 0;background:#e9eee3;border:1rpx solid #dce3d3;border-radius:24rpx;margin:12rpx 0 24rpx;}.summary>view{flex:1;text-align:center;border-right:1rpx solid #d2dac9;}.summary>view:last-child{border:0;}.summary text{display:block;font-size:24rpx;line-height:1.8;}.summary .number{font-size:64rpx;font-family:'Baskerville',serif;color:#576b50;line-height:1.2;margin-bottom:10rpx;}.summary .muted,.muted{font-size:22rpx;color:#8b8276;line-height:1.7;}.panel{border:1rpx solid #e6dfd4;border-radius:24rpx;background:#fffdf9;padding:28rpx;margin-bottom:22rpx;}.section-title{font-size:28rpx;}.chart{display:flex;gap:12rpx;margin-top:25rpx;}.column{flex:1;text-align:center;font-size:23rpx;color:#62715c;}.track{height:140rpx;display:flex;align-items:flex-end;justify-content:center;margin:14rpx 0;}.bar{width:34rpx;background:#97aa89;border-radius:6rpx 6rpx 0 0;}.trend-row{display:flex;justify-content:space-between;gap:16rpx;padding:20rpx 0;border-bottom:1rpx solid #eee8df;font-size:24rpx;}.footnote{display:block;font-size:22rpx;line-height:1.8;color:#938575;margin-top:22rpx;}.list-heading{display:flex;align-items:center;justify-content:space-between;gap:16rpx;min-height:50rpx;font-size:29rpx;}.content>.list-heading{margin:34rpx 0 20rpx;}.member{margin:0;padding:0;background:transparent;color:#5f6856;font-size:28rpx;line-height:64rpx;max-width:60%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}.member::after,.more::after{border:0;}.stars{font-size:29rpx;color:#ab8951;}.lesson{display:block;font-size:23rpx;color:#928575;margin:10rpx 0 22rpx;}.tags{display:flex;flex-wrap:wrap;gap:12rpx;}.tags text{padding:7rpx 16rpx;font-size:22rpx;border-radius:10rpx;background:#eff1e8;color:#748064;}.comment{display:block;white-space:pre-wrap;overflow-wrap:anywhere;font-size:28rpx;line-height:1.9;margin:22rpx 0;}.state{padding:50rpx 15rpx;text-align:center;font-size:26rpx;color:#8b806f;line-height:1.8;}.more{font-size:26rpx;background:#ebece2;color:#69745b;border-radius:20rpx;line-height:88rpx;}
</style>

View File

@@ -15,7 +15,7 @@
<view v-else-if="editableSlots.length === 0" class="empty-state"> <view v-else-if="editableSlots.length === 0" class="empty-state">
<text class="empty-icon">📭</text> <text class="empty-icon">📭</text>
<text class="empty-text">当日暂无排课</text> <text class="empty-text">当日暂无排课</text>
<text class="empty-sub">无模板匹配请手动添加时段或先配置排课模板</text> <text class="empty-sub">当日暂无默认时段请点击下方按钮手动添加</text>
</view> </view>
<!-- Slot list --> <!-- Slot list -->
@@ -40,9 +40,10 @@
<view class="slot-body"> <view class="slot-body">
<view class="time-section"> <view class="time-section">
<picker <picker
mode="time" mode="multiSelector"
:value="slot.startTime" :range="timePickerRange"
@change="(e: any) => updateSlotTime(slot, 'startTime', e.detail.value)" :value="timeToPickerIndex(slot.startTime)"
@change="(e: any) => updateSlotTime(slot, 'startTime', pickerIndexToTime(e.detail.value))"
> >
<view class="time-display"> <view class="time-display">
<text class="time-text">{{ slot.startTime }}</text> <text class="time-text">{{ slot.startTime }}</text>
@@ -50,9 +51,10 @@
</picker> </picker>
<text class="time-separator"></text> <text class="time-separator"></text>
<picker <picker
mode="time" mode="multiSelector"
:value="slot.endTime" :range="timePickerRange"
@change="(e: any) => updateSlotTime(slot, 'endTime', e.detail.value)" :value="timeToPickerIndex(slot.endTime)"
@change="(e: any) => updateSlotTime(slot, 'endTime', pickerIndexToTime(e.detail.value))"
> >
<view class="time-display"> <view class="time-display">
<text class="time-text">{{ slot.endTime }}</text> <text class="time-text">{{ slot.endTime }}</text>
@@ -112,8 +114,9 @@
<view class="modal-field"> <view class="modal-field">
<text class="modal-label">开始时间</text> <text class="modal-label">开始时间</text>
<picker <picker
mode="time" mode="multiSelector"
:value="addForm.startTime" :range="timePickerRange"
:value="timeToPickerIndex(addForm.startTime)"
@change="onAddStartTimeChange" @change="onAddStartTimeChange"
> >
<view class="picker-display"> <view class="picker-display">
@@ -157,11 +160,20 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import type { ScheduleSlotPreview } from '@mp-pilates/shared' import type { ScheduleSlotPreview } from '@mp-pilates/shared'
import { TimeSlotStatus } from '@mp-pilates/shared'
import CustomNavBar from '../../components/CustomNavBar.vue' import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system' import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from '../../stores/admin' import { useAdminStore } from './stores/admin'
import { formatDate } from '../../utils/format' import { formatDate } from '../../utils/format'
import DateSelector from '../../components/DateSelector.vue' 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 { interface EditableSlot {
readonly key: string readonly key: string
@@ -170,6 +182,7 @@ interface EditableSlot {
endTime: string endTime: string
capacity: number capacity: number
bookedCount: number bookedCount: number
status: TimeSlotStatus
isPublished: boolean isPublished: boolean
isNew: boolean isNew: boolean
isRemoved: boolean isRemoved: boolean
@@ -186,8 +199,8 @@ const showAddModal = ref(false)
const editableSlots = ref<EditableSlot[]>([]) const editableSlots = ref<EditableSlot[]>([])
const addForm = ref({ const addForm = ref({
startTime: '09:00', startTime: '09:30',
endTime: '10:00', endTime: '10:30',
capacityStr: '1', capacityStr: '1',
}) })
@@ -211,6 +224,7 @@ function mapPreviewToEditable(previews: readonly ScheduleSlotPreview[]): Editabl
endTime: p.endTime, endTime: p.endTime,
capacity: p.capacity, capacity: p.capacity,
bookedCount: p.bookedCount, bookedCount: p.bookedCount,
status: (p.status ?? TimeSlotStatus.OPEN) as TimeSlotStatus,
isPublished: p.isPublished, isPublished: p.isPublished,
isNew: false, isNew: false,
isRemoved: false, isRemoved: false,
@@ -270,23 +284,14 @@ function removeSlot(slot: EditableSlot) {
// ── Add slot ────────────────────────────────────────────── // ── Add slot ──────────────────────────────────────────────
/** 将 "HH:mm" 加一小时,最大 23:59 */
function addOneHour(time: string): string {
const [h, m] = time.split(':').map(Number)
const newH = Math.min(h + 1, 23)
// 如果原本就是 23:xx结束时间设为 23:59
if (h >= 23) return '23:59'
return String(newH).padStart(2, '0') + ':' + String(m).padStart(2, '0')
}
function onAddStartTimeChange(e: any) { function onAddStartTimeChange(e: any) {
const start = e.detail.value as string const start = pickerIndexToTime(e.detail.value as number[])
addForm.value.startTime = start addForm.value.startTime = start
addForm.value.endTime = addOneHour(start) addForm.value.endTime = addOneHourCapped(start)
} }
function openAddModal() { function openAddModal() {
addForm.value = { startTime: '09:00', endTime: '10:00', capacityStr: '1' } addForm.value = { startTime: '09:30', endTime: '10:30', capacityStr: '1' }
showAddModal.value = true showAddModal.value = true
} }
@@ -305,6 +310,10 @@ function submitAdd() {
uni.showToast({ title: '请选择时间', icon: 'none' }) uni.showToast({ title: '请选择时间', icon: 'none' })
return return
} }
if (addForm.value.startTime >= addForm.value.endTime) {
uni.showToast({ title: '结束时间须晚于开始时间', icon: 'none' })
return
}
if (isNaN(capacity) || capacity < 1) { if (isNaN(capacity) || capacity < 1) {
uni.showToast({ title: '请填写有效容量', icon: 'none' }) uni.showToast({ title: '请填写有效容量', icon: 'none' })
return return
@@ -317,6 +326,7 @@ function submitAdd() {
endTime: addForm.value.endTime, endTime: addForm.value.endTime,
capacity, capacity,
bookedCount: 0, bookedCount: 0,
status: TimeSlotStatus.OPEN,
isPublished: false, isPublished: false,
isNew: true, isNew: true,
isRemoved: false, isRemoved: false,
@@ -390,21 +400,24 @@ async function doPublish(slots: readonly EditableSlot[]) {
// ── Style helpers ───────────────────────────────────────── // ── Style helpers ─────────────────────────────────────────
function slotCardClass(slot: EditableSlot): string { function slotCardClass(slot: EditableSlot): string {
if (slot.status === TimeSlotStatus.CLOSED) return 'slot-card--closed'
if (slot.isNew) return 'slot-card--new' if (slot.isNew) return 'slot-card--new'
if (slot.isPublished) return 'slot-card--published' if (slot.isPublished) return 'slot-card--published'
return 'slot-card--template' return 'slot-card--template'
} }
function slotBadgeClass(slot: EditableSlot): string { function slotBadgeClass(slot: EditableSlot): string {
if (slot.status === TimeSlotStatus.CLOSED) return 'badge--closed'
if (slot.isNew) return 'badge--new' if (slot.isNew) return 'badge--new'
if (slot.isPublished) return 'badge--published' if (slot.isPublished) return 'badge--published'
return 'badge--template' return 'badge--template'
} }
function slotBadgeText(slot: EditableSlot): string { function slotBadgeText(slot: EditableSlot): string {
if (slot.status === TimeSlotStatus.CLOSED) return '已关闭'
if (slot.isNew) return '新增' if (slot.isNew) return '新增'
if (slot.isPublished) return '已发布' if (slot.isPublished) return '已发布'
return '来自模板' return '默认时段'
} }
// ── Lifecycle ───────────────────────────────────────────── // ── Lifecycle ─────────────────────────────────────────────
@@ -489,6 +502,12 @@ onMounted(() => {
border-color: #3498db; border-color: #3498db;
background: rgba(52, 152, 219, 0.04); background: rgba(52, 152, 219, 0.04);
} }
&--closed {
opacity: 0.55;
background: #fafafa;
border-color: #e5e5e5;
}
} }
/* ── Slot header ─────────────────────────── */ /* ── Slot header ─────────────────────────── */
@@ -510,6 +529,8 @@ onMounted(() => {
.badge--template .slot-badge-text { font-size: 22rpx; color: #b8860b; font-weight: 600; } .badge--template .slot-badge-text { font-size: 22rpx; color: #b8860b; font-weight: 600; }
.badge--new { background: rgba(52, 152, 219, 0.1); } .badge--new { background: rgba(52, 152, 219, 0.1); }
.badge--new .slot-badge-text { font-size: 22rpx; color: #3498db; font-weight: 600; } .badge--new .slot-badge-text { font-size: 22rpx; color: #3498db; font-weight: 600; }
.badge--closed { background: rgba(0, 0, 0, 0.06); }
.badge--closed .slot-badge-text { font-size: 22rpx; color: #888; font-weight: 600; }
.booked-info { } .booked-info { }
.booked-text { font-size: 22rpx; color: #e67e22; } .booked-text { font-size: 22rpx; color: #e67e22; }

View File

@@ -28,7 +28,12 @@
</view> </view>
<view class="form-row"> <view class="form-row">
<text class="form-label">开始时间</text> <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"> <view class="picker-display">
<text class="picker-text">{{ addForm.startTime || '请选择' }}</text> <text class="picker-text">{{ addForm.startTime || '请选择' }}</text>
<text class="picker-arrow"></text> <text class="picker-arrow"></text>
@@ -37,7 +42,12 @@
</view> </view>
<view class="form-row"> <view class="form-row">
<text class="form-label">结束时间</text> <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"> <view class="picker-display">
<text class="picker-text">{{ addForm.endTime || '请选择' }}</text> <text class="picker-text">{{ addForm.endTime || '请选择' }}</text>
<text class="picker-arrow"></text> <text class="picker-arrow"></text>
@@ -128,7 +138,7 @@
</picker> </picker>
</view> </view>
</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-wrap">
<view class="action-btn" :class="{ 'action-btn--loading': submitting }" @tap="submitGenerate"> <view class="action-btn" :class="{ 'action-btn--loading': submitting }" @tap="submitGenerate">
<text class="action-btn-text">{{ submitting ? '生成中...' : '批量生成' }}</text> <text class="action-btn-text">{{ submitting ? '生成中...' : '批量生成' }}</text>
@@ -142,9 +152,16 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import CustomNavBar from '../../components/CustomNavBar.vue' import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system' import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from '../../stores/admin' import { useAdminStore } from './stores/admin'
import { formatDate } from '../../utils/format' import { formatDate } from '../../utils/format'
import type { TimeSlot } from '@mp-pilates/shared' 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 adminStore = useAdminStore()
@@ -157,8 +174,8 @@ const slotsLoading = ref(false)
// ── Add slot form ──────────────────────────────────────────────── // ── Add slot form ────────────────────────────────────────────────
const addForm = ref({ const addForm = ref({
date: formatDate(new Date()), date: formatDate(new Date()),
startTime: '09:00', startTime: '09:30',
endTime: '10:00', endTime: '10:30',
capacityStr: '10', capacityStr: '10',
}) })
@@ -168,6 +185,10 @@ async function submitAddSlot() {
uni.showToast({ title: '请填写完整信息', icon: 'none' }) uni.showToast({ title: '请填写完整信息', icon: 'none' })
return return
} }
if (addForm.value.startTime >= addForm.value.endTime) {
uni.showToast({ title: '结束时间须晚于开始时间', icon: 'none' })
return
}
const capacity = parseInt(addForm.value.capacityStr, 10) const capacity = parseInt(addForm.value.capacityStr, 10)
submitting.value = true submitting.value = true
try { try {

View File

@@ -1,9 +1,9 @@
import type { ReviewEntry, ReviewSummary } from '@mp-pilates/shared'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import { get, post, put, del } from '../utils/request' import { get, post, put, del } from '../../../utils/request'
import type { import type {
WeekTemplate, TeachingAnalytics,
WeekTemplateInput,
CardType, CardType,
CreateCardTypeDto, CreateCardTypeDto,
UpdateCardTypeDto, UpdateCardTypeDto,
@@ -15,25 +15,54 @@ import type {
PaginatedData, PaginatedData,
ScheduleSlotPreview, ScheduleSlotPreview,
PublishDaySlotsDto, PublishDaySlotsDto,
CreateStudioUploadCredentialDto,
StudioUploadCredential,
AdminMemberSummary,
AdminMemberDetail,
LessonSupplementRecord,
CreateLessonSupplementDto,
UpdateAdminMemberProfileDto,
AdminArrangeBookingDto,
MembershipWithCardType,
BookingWithDetails,
GrowthTodayDashboard,
GrowthLeadSummary,
GrowthLeadDetail,
ProfessionalAssessmentSessionRecord,
CreateProfessionalAssessmentDto,
TrainingPlanRecord,
CreateTrainingPlanDto,
} from '@mp-pilates/shared' } from '@mp-pilates/shared'
export interface AdminStats { interface LegacyPaginatedData<T> {
todayBookings: number readonly data: readonly T[]
totalOrders: number readonly total: number
totalBookings: number readonly page: number
readonly limit: number
} }
export interface MemberSummary { function normalizePaginatedData<T>(
userId: string result: PaginatedData<T> | LegacyPaginatedData<T>,
openid: string ): PaginatedData<T> {
nickname: string if ('items' in result) {
phone: string | null return {
avatarUrl: string | null items: [...result.items],
totalBookings: number total: result.total,
completedBookings: number page: result.page,
cancelledBookings: number limit: result.limit,
}
}
return {
items: [...result.data],
total: result.total,
page: result.page,
limit: result.limit,
}
} }
export type MemberSummary = AdminMemberSummary
export interface UserMembership { export interface UserMembership {
userId: string userId: string
membership: { membership: {
@@ -54,20 +83,8 @@ export interface UserMembership {
} }
export const useAdminStore = defineStore('admin', () => { export const useAdminStore = defineStore('admin', () => {
// ── Week templates ─────────────────────────────────────────────── async function fetchReviews(userId = '', page = 1) { return get<{ data: ReviewEntry[]; total: number }>('/admin/reviews', { ...(userId ? { userId } : {}), page }) }
const weekTemplates = ref<WeekTemplate[]>([]) async function fetchReviewTrend(month: string) { return get<ReviewSummary[]>('/admin/reviews/trend', { month }) }
async function fetchWeekTemplates(): Promise<WeekTemplate[]> {
const data = await get<WeekTemplate[]>('/admin/week-template')
weekTemplates.value = data
return data
}
async function saveWeekTemplates(templates: WeekTemplateInput[]): Promise<WeekTemplate[]> {
const data = await put<WeekTemplate[]>('/admin/week-template', { templates })
weekTemplates.value = data
return data
}
// ── Card types ─────────────────────────────────────────────────── // ── Card types ───────────────────────────────────────────────────
const cardTypes = ref<CardType[]>([]) const cardTypes = ref<CardType[]>([])
@@ -79,13 +96,13 @@ export const useAdminStore = defineStore('admin', () => {
} }
async function createCardType(dto: CreateCardTypeDto): Promise<CardType> { async function createCardType(dto: CreateCardTypeDto): Promise<CardType> {
const data = await post<CardType>('/admin/card-types', dto) const data = await post<CardType>('/admin/card-types', dto as unknown as Record<string, unknown>)
await fetchCardTypes() await fetchCardTypes()
return data return data
} }
async function updateCardType(id: string, dto: UpdateCardTypeDto): Promise<CardType> { async function updateCardType(id: string, dto: UpdateCardTypeDto): Promise<CardType> {
const data = await put<CardType>(`/admin/card-types/${id}`, dto) const data = await put<CardType>(`/admin/card-types/${id}`, dto as unknown as Record<string, unknown>)
await fetchCardTypes() await fetchCardTypes()
return data return data
} }
@@ -106,18 +123,31 @@ export const useAdminStore = defineStore('admin', () => {
} }
async function saveStudioConfig(dto: UpdateStudioConfigDto): Promise<StudioConfig> { async function saveStudioConfig(dto: UpdateStudioConfigDto): Promise<StudioConfig> {
const data = await put<StudioConfig>('/admin/studio/info', dto) const data = await put<StudioConfig>('/admin/studio/info', dto as unknown as Record<string, unknown>)
studioConfig.value = data studioConfig.value = data
return data return data
} }
async function createStudioUploadCredential(
dto: CreateStudioUploadCredentialDto,
): Promise<StudioUploadCredential> {
return post<StudioUploadCredential>(
'/admin/studio/upload-credentials',
dto as unknown as Record<string, unknown>,
)
}
// ── Orders ─────────────────────────────────────────────────────── // ── Orders ───────────────────────────────────────────────────────
async function fetchAdminOrders(params: { async function fetchAdminOrders(params: {
page?: number page?: number
limit?: number limit?: number
status?: string status?: string
}): Promise<PaginatedData<OrderWithDetails>> { }): Promise<PaginatedData<OrderWithDetails>> {
return get<PaginatedData<OrderWithDetails>>('/admin/orders', params) const result = await get<PaginatedData<OrderWithDetails> | LegacyPaginatedData<OrderWithDetails>>(
'/admin/orders',
params,
)
return normalizePaginatedData(result)
} }
// ── Bookings ───────────────────────────────────────────────────── // ── Bookings ─────────────────────────────────────────────────────
@@ -148,20 +178,50 @@ export const useAdminStore = defineStore('admin', () => {
return get<UserMembership>(`/admin/members/${userId}/membership`) 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( async function updateUserMembership(
userId: string, userId: string,
dto: { dto: {
membershipId?: string
cardTypeId: string cardTypeId: string
remainingTimes?: number | null remainingTimes?: number | null
startDate: string startDate: string
expireDate: string expireDate: string
}, },
): Promise<any> { ): Promise<MembershipWithCardType> {
return put<any>(`/admin/members/${userId}/membership`, dto) return put<MembershipWithCardType>(`/admin/members/${userId}/membership`, dto)
} }
async function deleteUserMembership(userId: string): Promise<void> { async function deleteUserMembership(userId: string, membershipId: string): Promise<void> {
return del<void>(`/admin/members/${userId}/membership`) return del<void>(
`/admin/members/${userId}/membership?membershipId=${encodeURIComponent(membershipId)}`,
)
} }
// ── Time slots ─────────────────────────────────────────────────── // ── Time slots ───────────────────────────────────────────────────
@@ -170,7 +230,7 @@ export const useAdminStore = defineStore('admin', () => {
} }
async function createManualSlot(dto: CreateManualSlotDto): Promise<TimeSlot> { async function createManualSlot(dto: CreateManualSlotDto): Promise<TimeSlot> {
return post<TimeSlot>('/admin/time-slot/manual', dto) return post<TimeSlot>('/admin/time-slot/manual', dto as unknown as Record<string, unknown>)
} }
async function closeSlot(id: string): Promise<TimeSlot> { async function closeSlot(id: string): Promise<TimeSlot> {
@@ -188,7 +248,7 @@ export const useAdminStore = defineStore('admin', () => {
async function fetchSchedulePreview(date: string): Promise<ScheduleSlotPreview[]> { async function fetchSchedulePreview(date: string): Promise<ScheduleSlotPreview[]> {
scheduleLoading.value = true scheduleLoading.value = true
try { try {
const data = await get<ScheduleSlotPreview[]>('/admin/schedule/preview', { date }) const data = await previewScheduleByDate(date)
schedulePreview.value = data schedulePreview.value = data
return data return data
} finally { } finally {
@@ -196,26 +256,54 @@ export const useAdminStore = defineStore('admin', () => {
} }
} }
async function previewScheduleByDate(date: string): Promise<ScheduleSlotPreview[]> {
return get<ScheduleSlotPreview[]>('/admin/schedule/preview', { date })
}
async function publishDaySlots(dto: PublishDaySlotsDto): Promise<void> { async function publishDaySlots(dto: PublishDaySlotsDto): Promise<void> {
await post('/admin/schedule/publish', dto as unknown as Record<string, unknown>) await post('/admin/schedule/publish', dto as unknown as Record<string, unknown>)
await fetchSchedulePreview(dto.date) await fetchSchedulePreview(dto.date)
} }
// ── Dashboard stats ────────────────────────────────────────────── // ── Teaching analytics ─────────────────────────────────────────
async function fetchDashboardStats(): Promise<AdminStats> { async function fetchTeachingAnalytics(month: string): Promise<TeachingAnalytics> {
return get<AdminStats>('/admin/stats') return get<TeachingAnalytics>('/admin/teaching-analytics', { month })
}
async function fetchGrowthToday() {
return get<GrowthTodayDashboard>('/admin/growth/today')
}
async function fetchGrowthLeads(params: { page?: number; search?: string; stage?: string } = {}) {
return get<PaginatedData<GrowthLeadSummary>>('/admin/growth/leads', params)
}
async function fetchGrowthLead(id: string) {
return get<GrowthLeadDetail>(`/admin/growth/leads/${id}`)
}
async function createProfessionalAssessment(userId: string, dto: CreateProfessionalAssessmentDto) {
return post<ProfessionalAssessmentSessionRecord>(
`/admin/members/${userId}/professional-assessments`,
dto as unknown as Record<string, unknown>,
)
}
async function createTrainingPlan(userId: string, dto: CreateTrainingPlanDto) {
return post<TrainingPlanRecord>(
`/admin/members/${userId}/training-plans`,
dto as unknown as Record<string, unknown>,
)
} }
return { return {
fetchReviews, fetchReviewTrend,
fetchTeachingAnalytics,
// State // State
weekTemplates,
cardTypes, cardTypes,
studioConfig, studioConfig,
schedulePreview, schedulePreview,
scheduleLoading, scheduleLoading,
// Week templates
fetchWeekTemplates,
saveWeekTemplates,
// Card types // Card types
fetchCardTypes, fetchCardTypes,
createCardType, createCardType,
@@ -224,12 +312,19 @@ export const useAdminStore = defineStore('admin', () => {
// Studio // Studio
fetchStudioConfig, fetchStudioConfig,
saveStudioConfig, saveStudioConfig,
createStudioUploadCredential,
// Orders // Orders
fetchAdminOrders, fetchAdminOrders,
// Bookings // Bookings
fetchAdminBookings, fetchAdminBookings,
// Members // Members
fetchMembers, fetchMembers,
fetchMemberDetail,
fetchLessonSupplements,
createLessonSupplement,
revokeLessonSupplement,
updateMemberProfile,
arrangeMemberBooking,
getUserMembership, getUserMembership,
updateUserMembership, updateUserMembership,
deleteUserMembership, deleteUserMembership,
@@ -240,8 +335,12 @@ export const useAdminStore = defineStore('admin', () => {
generateSlots, generateSlots,
// Schedule // Schedule
fetchSchedulePreview, fetchSchedulePreview,
previewScheduleByDate,
publishDaySlots, publishDaySlots,
// Stats fetchGrowthToday,
fetchDashboardStats, fetchGrowthLeads,
fetchGrowthLead,
createProfessionalAssessment,
createTrainingPlan,
} }
}) })

File diff suppressed because it is too large Load Diff

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,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

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
<template> <template>
<view class="booking-page"> <view class="booking-page" :style="{ height: pageHeight }">
<!-- Status bar spacing --> <!-- Status bar spacing -->
<view class="status-bar" :style="{ height: statusBarHeight }" /> <view class="status-bar" :style="{ height: statusBarHeight }" />
@@ -10,15 +10,20 @@
<!-- Date & period filters --> <!-- Date & period filters -->
<view class="filter-header"> <view class="filter-header">
<DateSelector v-model="selectedDate" @select="onDateSelect" /> <view class="calendar-heading">
<TimePeriodFilter v-model="selectedPeriod" @change="onPeriodChange" /> <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> </view>
<!-- Slot list --> <!-- Slot list -->
<scroll-view <scroll-view
class="slot-scroll" class="slot-scroll"
scroll-y scroll-y
:style="{ height: scrollHeight }" :scroll-into-view="targetSlotId"
scroll-with-animation
refresher-enabled refresher-enabled
:refresher-triggered="refreshing" :refresher-triggered="refreshing"
@refresherrefresh="onRefresh" @refresherrefresh="onRefresh"
@@ -37,12 +42,7 @@
<!-- Empty state --> <!-- Empty state -->
<view v-else-if="filteredSlots.length === 0" class="empty-wrap"> <view v-else-if="filteredSlots.length === 0" class="empty-wrap">
<view class="empty-illustration"> <text class="empty-text">这个时段还没有课程</text>
<view class="empty-circle outer" />
<view class="empty-circle inner" />
<view class="empty-dot" />
</view>
<text class="empty-text">当日暂无可约时段</text>
<text class="empty-sub">请选择其他日期或时段查看</text> <text class="empty-sub">请选择其他日期或时段查看</text>
</view> </view>
@@ -50,19 +50,22 @@
<view v-else class="slot-list"> <view v-else class="slot-list">
<!-- Date summary --> <!-- Date summary -->
<view class="date-summary"> <view class="date-summary">
<text class="date-summary-text"> <text class="date-summary-title">{{ selectedDayLabel }}</text>
{{ filteredSlots.length }} 可选时段 <text class="date-summary-text">{{ filteredSlots.length }} 个时段</text>
</text>
</view> </view>
<SlotCard <view
v-for="item in filteredSlots" v-for="item in filteredSlots"
:id="`slot-${item.id}`"
:key="item.id" :key="item.id"
:time-slot="item" >
@book="onBookTap" <SlotCard
@cancel="onCancelTap" :time-slot="item"
@card-tap="onSlotCardTap" @book="onBookTap"
/> @cancel="onCancelTap"
@card-tap="onSlotCardTap"
/>
</view>
</view> </view>
<!-- Bottom padding spacer --> <!-- Bottom padding spacer -->
@@ -71,7 +74,8 @@
<!-- Confirm popup --> <!-- Confirm popup -->
<BookingConfirmPopup <BookingConfirmPopup
:visible="showConfirmPopup" v-if="showConfirmPopup"
:visible="true"
:time-slot="pendingSlot" :time-slot="pendingSlot"
:memberships="userStore.activeMemberships as MembershipWithCardType[]" :memberships="userStore.activeMemberships as MembershipWithCardType[]"
@confirm="onConfirmBooking" @confirm="onConfirmBooking"
@@ -81,18 +85,20 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted, nextTick, getCurrentInstance } from 'vue'
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app' import { onResize, onShareAppMessage, onShareTimeline, onShow } from '@dcloudio/uni-app'
import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pilates/shared' import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pilates/shared'
import { TIME_PERIODS } from '@mp-pilates/shared' import { BookingStatus, TIME_PERIODS } from '@mp-pilates/shared'
import { useBookingStore } from '../../stores/booking' import { useBookingStore } from '../../stores/booking'
import { useUserStore } from '../../stores/user' import { useUserStore } from '../../stores/user'
import { formatDate } from '../../utils/format' import { getErrorMessage } from '../../utils/auth'
import { formatDate, isSlotPast } from '../../utils/format'
import { getSystemLayout } from '../../utils/system' import { getSystemLayout } from '../../utils/system'
import DateSelector from '../../components/DateSelector.vue' import DateSelector from '../../components/DateSelector.vue'
import TimePeriodFilter from '../../components/TimePeriodFilter.vue' import TimePeriodFilter from '../../components/TimePeriodFilter.vue'
import SlotCard from '../../components/SlotCard.vue' import SlotCard from '../../components/SlotCard.vue'
import BookingConfirmPopup from '../../components/BookingConfirmPopup.vue' import BookingConfirmPopup from '../../components/BookingConfirmPopup.vue'
import { requestBookingCancelSubscriptionMessage } from '../../utils/wechat-subscription'
type PeriodKey = keyof typeof TIME_PERIODS | null type PeriodKey = keyof typeof TIME_PERIODS | null
@@ -106,6 +112,10 @@ const selectedPeriod = ref<PeriodKey>(null)
const showConfirmPopup = ref(false) const showConfirmPopup = ref(false)
const pendingSlot = ref<TimeSlotWithBookingStatus | null>(null) const pendingSlot = ref<TimeSlotWithBookingStatus | null>(null)
const refreshing = ref(false) const refreshing = ref(false)
const targetSlotId = ref('')
// 仅在「每次启动首次进入预约 TAB」时自动定位到当前时段及以后
// 切换日期/时段或下拉刷新后不再重置位置,避免打断用户的浏览位置。
const hasAutoScrolled = ref(false)
// ─── 微信分享 ─────────────────────────────────────────────── // ─── 微信分享 ───────────────────────────────────────────────
onShareAppMessage(() => { onShareAppMessage(() => {
@@ -125,28 +135,26 @@ onShareTimeline(() => {
// ─── Layout ─────────────────────────────────────────────── // ─── Layout ───────────────────────────────────────────────
const statusBarHeight = ref('20px') const statusBarHeight = ref('20px')
const scrollHeight = ref('500px') const pageHeight = ref('100vh')
// Heights of static elements above scroll-view (in rpx, converted to px)
const PAGE_HEADER_RPX = 88 // title bar height
const FILTER_HEADER_RPX = 240 // DateSelector + TimePeriodFilter
const TABBAR_RPX = 100
function updateLayout() { function updateLayout() {
const { statusBarHeight: statusBarPx, windowWidth } = getSystemLayout() statusBarHeight.value = `${getSystemLayout().statusBarHeight}px`
const ratio = windowWidth / 750 // The mini-program window already excludes its native tab bar. Flex layout
statusBarHeight.value = `${statusBarPx}px` // gives the remaining height to the list as the filters change size.
pageHeight.value = `${uni.getWindowInfo().windowHeight}px`
const headerPx = Math.round(PAGE_HEADER_RPX * ratio)
const filterPx = Math.round(FILTER_HEADER_RPX * ratio)
const tabbarPx = Math.round(TABBAR_RPX * ratio)
// scroll-view fills remaining space: window - statusBar - pageHeader - filters - tabbar
const { windowHeight } = uni.getWindowInfo()
const remaining = windowHeight - statusBarPx - headerPx - filterPx - tabbarPx
scrollHeight.value = `${remaining}px`
} }
updateLayout() 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 ─────────────────────────────────────── // ─── Filtered slots ───────────────────────────────────────
const filteredSlots = computed<TimeSlotWithBookingStatus[]>(() => { const filteredSlots = computed<TimeSlotWithBookingStatus[]>(() => {
@@ -171,6 +179,94 @@ async function onRefresh() {
refreshing.value = false refreshing.value = false
} }
const instance = getCurrentInstance()
let isAutoScrolling = false
/**
* 轮询等待目标节点在视图层完成挂载与排版
*/
function waitForElement(selector: string, maxRetries = 10, interval = 50): Promise<boolean> {
return new Promise((resolve) => {
let retries = 0
function check() {
const query = instance?.proxy
? uni.createSelectorQuery().in(instance.proxy)
: uni.createSelectorQuery()
const q = query
.select(selector)
.boundingClientRect((data) => {
const node = Array.isArray(data) ? data[0] : data
if (node && node.top !== undefined) {
resolve(true)
} else if (retries < maxRetries) {
retries++
setTimeout(check, interval)
} else {
resolve(false)
}
})
// 触发查询
q['exec']()
}
check()
})
}
// 首次进入时滚动到当天第一个未开始的课程("本时段及以后"
async function scrollToUpcoming() {
if (hasAutoScrolled.value || isAutoScrolling) return
if (bookingStore.loadingSlots) return
const slots = filteredSlots.value
if (slots.length === 0) {
// 列表还没加载出来(加载中或当天无课),不要把「仅一次」用掉。
return
}
const upcomingIndex = slots.findIndex((slot) => !isSlotPast(slot.date, slot.startTime))
if (upcomingIndex === -1) {
// 当天所有课程均已结束,标记已滚动过,留在当前位置
hasAutoScrolled.value = true
return
}
if (upcomingIndex === 0) {
// 本时段及以后的第一个课程正好是列表第 1 项,页面已经在顶部,无需额外滚动
hasAutoScrolled.value = true
return
}
isAutoScrolling = true
const dateWhenStarted = selectedDate.value
const upcoming = slots[upcomingIndex]
const targetId = `slot-${upcoming.id}`
try {
// 等待 Vue 虚拟 DOM 提交并分发 setData
await nextTick()
// 确保原生视图层已完成该节点的挂载与布局排版
const isReady = await waitForElement(`#${targetId}`, 10, 50)
if (!isReady || selectedDate.value !== dateWhenStarted) {
// 节点尚未在视图层就绪(例如 Tab 处于后台未完成渲染)或用户已切换日期,不锁定 hasAutoScrolled留待 onShow 或后续就绪时执行
return
}
if (targetSlotId.value === targetId) {
targetSlotId.value = ''
await new Promise((resolve) => setTimeout(resolve, 50))
if (selectedDate.value !== dateWhenStarted) return
}
targetSlotId.value = targetId
hasAutoScrolled.value = true
} finally {
isAutoScrolling = false
}
}
// ─── Event handlers ─────────────────────────────────────── // ─── Event handlers ───────────────────────────────────────
function onDateSelect(date: string) { function onDateSelect(date: string) {
selectedDate.value = date selectedDate.value = date
@@ -195,6 +291,19 @@ function onSlotCardTap(slot: TimeSlotWithBookingStatus) {
// ─── Book flow ──────────────────────────────────────────── // ─── Book flow ────────────────────────────────────────────
async function onBookTap(slot: TimeSlotWithBookingStatus) { async function onBookTap(slot: TimeSlotWithBookingStatus) {
if (slot.isBookedByMe) {
if (slot.myBookingId) {
uni.navigateTo({ url: `/pages/booking/detail?id=${slot.myBookingId}` })
return
}
const title = slot.myBookingStatus === BookingStatus.PENDING_CONFIRMATION
? '该时段已预约,等待老师确认'
: '该时段已预约'
uni.showToast({ title, icon: 'none' })
return
}
// 1. Ensure logged in // 1. Ensure logged in
if (!userStore.loggedIn) { if (!userStore.loggedIn) {
uni.showModal({ uni.showModal({
@@ -208,8 +317,8 @@ async function onBookTap(slot: TimeSlotWithBookingStatus) {
if (!isNewUser) { if (!isNewUser) {
onBookTap(slot) onBookTap(slot)
} }
} catch { } catch (err: unknown) {
uni.showToast({ title: '登录失败', icon: 'none' }) uni.showToast({ title: getErrorMessage(err, '登录失败'), icon: 'none' })
} }
} }
}, },
@@ -260,6 +369,12 @@ async function onConfirmBooking(payload: { timeSlotId: string; membershipId: str
async function onCancelTap(slot: TimeSlotWithBookingStatus) { async function onCancelTap(slot: TimeSlotWithBookingStatus) {
if (!slot.myBookingId) return if (!slot.myBookingId) return
try {
await requestBookingCancelSubscriptionMessage()
} catch (err: unknown) {
console.warn('[subscribe] cancel pre-subscribe failed', err)
}
uni.showModal({ uni.showModal({
title: '取消预约', title: '取消预约',
content: '确定要取消这个预约吗?', content: '确定要取消这个预约吗?',
@@ -286,223 +401,52 @@ async function onCancelTap(slot: TimeSlotWithBookingStatus) {
// ─── Lifecycle ──────────────────────────────────────────── // ─── Lifecycle ────────────────────────────────────────────
onMounted(async () => { onMounted(async () => {
const tasks: Promise<unknown>[] = [loadSlots(selectedDate.value)]
// Load memberships if logged in but not yet fetched // Load memberships if logged in but not yet fetched
if (userStore.loggedIn && userStore.activeMemberships.length === 0) { if (userStore.loggedIn && userStore.activeMemberships.length === 0) {
await userStore.fetchMemberships() tasks.push(userStore.fetchMemberships())
}
await Promise.all(tasks)
// 首次进入:自动定位到当天本时段及以后的第一个课程
await scrollToUpcoming()
})
onShow(async () => {
// 如果首次进入时页面在后台预加载完成,或从其他 Tab 切入时定位未生效,在页面可见时触发定位
if (!hasAutoScrolled.value && filteredSlots.value.length > 0 && !bookingStore.loadingSlots) {
await scrollToUpcoming()
} }
// Load today's slots
await loadSlots(selectedDate.value)
}) })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.booking-page { .booking-page { height: 100vh; background: #fbf9f6; display: flex; flex-direction: column; overflow: hidden; }
height: 100vh; .status-bar { flex-shrink: 0; }
background: $primary-bg; .page-header { flex-shrink: 0; height: 88rpx; display: flex; align-items: center; justify-content: center; }
display: flex; .page-title { font-size: 34rpx; font-weight: 500; color: #514943; }
flex-direction: column; .filter-header { flex-shrink: 0; border-bottom: 1rpx solid #eee8e3; }
overflow: hidden; .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; }
/* ── Status bar ───────────────────────────────────── */ .slot-scroll { flex: 1; height: 0; min-height: 0; width: 100%; box-sizing: border-box; }
.status-bar { .slot-list { display: flex; flex-direction: column; padding-top: 28rpx; }
flex-shrink: 0; .date-summary { display: flex; justify-content: space-between; align-items: baseline; gap: 16rpx; padding: 0 32rpx 20rpx; }
background: #fff; .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; }
/* ── Page header ──────────────────────────────────── */ .skeleton-card { height: 200rpx; box-sizing: border-box; border-radius: 28rpx; background: #fff; display: flex; align-items: center; padding: 28rpx; gap: 20rpx; }
.page-header { .skeleton-time, .skeleton-title, .skeleton-sub, .skeleton-btn {
flex-shrink: 0; border-radius: 10rpx;
height: 88rpx; background: linear-gradient(90deg, #f0eae5 25%, #faf7f3 50%, #f0eae5 75%);
display: flex; background-size: 400% 100%; animation: shimmer 1.4s infinite;
align-items: center;
justify-content: center;
background: #fff;
}
.page-title {
font-size: 34rpx;
font-weight: 600;
color: #1a1a2e;
}
/* ── Filter header ────────────────────────────────── */
.filter-header {
flex-shrink: 0;
background: #fff;
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.04);
}
/* ── Scroll container ──────────────────────────────── */
.slot-scroll {
flex: 1;
width: 100%;
box-sizing: border-box;
}
/* ── Slot list ─────────────────────────────────────── */
.slot-list {
display: flex;
flex-direction: column;
padding: 24rpx 0 0;
}
/* ── Date summary ──────────────────────────────────── */
.date-summary {
padding: 0 24rpx 16rpx;
}
.date-summary-text {
font-size: 24rpx;
color: #999;
font-weight: 400;
}
/* ── Loading skeleton ──────────────────────────────── */
.loading-wrap {
display: flex;
flex-direction: column;
gap: 24rpx;
padding: 28rpx 24rpx;
}
.skeleton-card {
height: 220rpx;
border-radius: 20rpx;
background: #fff;
display: flex;
flex-direction: row;
align-items: center;
padding: 28rpx 48rpx;
gap: 20rpx;
margin: 0 24rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.03);
}
.skeleton-time {
width: 90rpx;
height: 80rpx;
border-radius: 12rpx;
background: linear-gradient(90deg, $primary-border 25%, $primary-light 50%, $primary-border 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
flex-shrink: 0;
}
.skeleton-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 12rpx;
}
.skeleton-title {
width: 60%;
height: 28rpx;
border-radius: 8rpx;
background: linear-gradient(90deg, $primary-border 25%, $primary-light 50%, $primary-border 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
.skeleton-sub {
width: 40%;
height: 20rpx;
border-radius: 6rpx;
background: linear-gradient(90deg, $primary-border 25%, $primary-light 50%, $primary-border 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
.skeleton-btn {
width: 100rpx;
height: 60rpx;
border-radius: 20rpx;
background: linear-gradient(90deg, $primary-border 25%, $primary-light 50%, $primary-border 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
flex-shrink: 0;
}
/* ── Empty state ───────────────────────────────────── */
.empty-wrap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 40rpx 80rpx;
gap: 0;
}
/* Zen-inspired geometric illustration */
.empty-illustration {
position: relative;
width: 200rpx;
height: 200rpx;
margin-bottom: 56rpx;
}
.empty-circle {
position: absolute;
border-radius: 50%;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
&.outer {
width: 180rpx;
height: 180rpx;
border: 2rpx solid $primary-border;
animation: breathe 3s ease-in-out infinite;
}
&.inner {
width: 120rpx;
height: 120rpx;
background: linear-gradient(135deg, $primary-light 0%, $primary-color 50%, $primary-dark 100%);
opacity: 0.6;
animation: breathe 3s ease-in-out infinite 0.5s;
}
}
.empty-dot {
position: absolute;
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background: $primary-dark;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
animation: pulse 2s ease-in-out infinite;
}
.empty-text {
font-size: 32rpx;
color: $primary-dark;
font-weight: 600;
letter-spacing: 2rpx;
margin-bottom: 16rpx;
}
.empty-sub {
font-size: 26rpx;
color: $primary-color;
letter-spacing: 1rpx;
}
@keyframes breathe {
0%, 100% { transform: translate(-50%, -50%) scale(1); opacity: 0.6; }
50% { transform: translate(-50%, -50%) scale(1.05); opacity: 0.4; }
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
50% { opacity: 0.5; transform: translate(-50%, -50%) scale(0.8); }
}
/* ── Bottom spacer ─────────────────────────────────── */
.scroll-bottom-spacer {
height: 48rpx;
} }
.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> </style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,46 +1,22 @@
<template> <template>
<view class="home-page"> <view class="home-page" :style="{ height: pageHeight }">
<!-- Brand Banner fixed background layer -->
<view class="banner-fixed"> <view class="banner-fixed">
<BrandBanner :studio-info="studioStore.studioInfo" /> <BrandBanner :studio-info="studioStore.studioInfo" />
</view> </view>
<scroll-view class="page-scroll" scroll-y :scroll-into-view="scrollTarget"
<!-- Pull-to-refresh wrapper scrollable foreground --> scroll-with-animation refresher-enabled :refresher-triggered="refreshing"
<scroll-view @refresherrefresh="handleRefresh" @refresherrestore="refreshing = false">
class="page-scroll"
scroll-y
:scroll-top="scrollTop"
:scroll-with-animation="true"
:refresher-enabled="true"
:refresher-triggered="refreshing"
@refresherrefresh="handleRefresh"
@refresherrestore="refreshing = false"
>
<!-- Transparent spacer to reveal Banner behind -->
<view class="banner-spacer" /> <view class="banner-spacer" />
<!-- Floating card with rounded top corners -->
<view class="floating-card"> <view class="floating-card">
<!-- Drag indicator --> <view class="card-handle"><view class="card-handle-bar" /></view>
<view class="card-handle">
<view class="card-handle-bar" />
</view>
<!-- Studio Info (photo strip + address/phone) -->
<StudioInfo :studio-info="studioStore.studioInfo" />
<!-- Quick Entry (login / trial / book / renew) -->
<QuickEntry @scroll-to-card-shop="scrollToCardShop" /> <QuickEntry @scroll-to-card-shop="scrollToCardShop" />
<!-- Upcoming Bookings -->
<UpcomingBooking /> <UpcomingBooking />
<ReviewSummaryCard />
<!-- Card Shop (vertical list) --> <StudioInfo :studio-info="studioStore.studioInfo" />
<view :id="cardShopAnchorId"> <view :id="cardShopAnchorId">
<CardShop ref="cardShopRef" /> <CardShop ref="cardShopRef" />
</view> </view>
<AboutSection />
<!-- Bottom padding for tab bar -->
<view class="bottom-padding" /> <view class="bottom-padding" />
</view> </view>
</scroll-view> </scroll-view>
@@ -49,13 +25,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, nextTick, onUnmounted } from 'vue' import { ref, nextTick, onUnmounted } from 'vue'
import { onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app' import { onShow, onResize, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import BrandBanner from '../../components/BrandBanner.vue' import BrandBanner from '../../components/BrandBanner.vue'
import ReviewSummaryCard from '../../components/ReviewSummaryCard.vue'
import StudioInfo from '../../components/StudioInfo.vue' import StudioInfo from '../../components/StudioInfo.vue'
import QuickEntry from '../../components/QuickEntry.vue' import QuickEntry from '../../components/QuickEntry.vue'
import UpcomingBooking from '../../components/UpcomingBooking.vue' import UpcomingBooking from '../../components/UpcomingBooking.vue'
import CardShop from '../../components/CardShop.vue' import CardShop from '../../components/CardShop.vue'
import AboutSection from '../../components/AboutSection.vue'
import { useUserStore } from '../../stores/user' import { useUserStore } from '../../stores/user'
import { useStudioStore } from '../../stores/studio' import { useStudioStore } from '../../stores/studio'
@@ -85,7 +63,9 @@ onShareTimeline(() => {
const refreshing = ref(false) const refreshing = ref(false)
const cardShopRef = ref<InstanceType<typeof CardShop> | null>(null) const cardShopRef = ref<InstanceType<typeof CardShop> | null>(null)
const cardShopAnchorId = 'card-shop-anchor' const cardShopAnchorId = 'card-shop-anchor'
const scrollTop = ref(0) const scrollTarget = ref('')
const pageHeight = ref(`${uni.getWindowInfo().windowHeight}px`)
onResize(() => { pageHeight.value = `${uni.getWindowInfo().windowHeight}px` })
const pendingScrollToCardShop = ref(false) const pendingScrollToCardShop = ref(false)
// Listen for cross-page scroll request (e.g. from booking page "去购买") // Listen for cross-page scroll request (e.g. from booking page "去购买")
@@ -114,6 +94,7 @@ async function refreshData() {
if (userStore.loggedIn) { if (userStore.loggedIn) {
tasks.push( tasks.push(
userStore.fetchProfile(),
userStore.fetchMemberships(), userStore.fetchMemberships(),
bookingStore.fetchUpcomingBookings(), bookingStore.fetchUpcomingBookings(),
) )
@@ -122,7 +103,9 @@ async function refreshData() {
await Promise.allSettled(tasks) await Promise.allSettled(tasks)
// Also refresh card shop // Also refresh card shop
cardShopRef.value?.fetchCardTypes() await Promise.allSettled([
cardShopRef.value?.fetchCardTypes(),
])
} }
async function handleRefresh() { async function handleRefresh() {
@@ -134,78 +117,30 @@ async function handleRefresh() {
} }
} }
function scrollToCardShop() { async function scrollToCardShop() {
// Reset first so setting the same value still triggers scroll scrollTarget.value = ''
scrollTop.value = 0 await nextTick()
nextTick(() => { scrollTarget.value = cardShopAnchorId
uni.createSelectorQuery()
.select(`#${cardShopAnchorId}`)
.boundingClientRect()
.selectViewport()
.scrollOffset((res) => {
if (res) {
scrollTop.value = (res as UniApp.NodeInfo).scrollTop ?? 0
}
})
.exec()
})
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.home-page { .home-page { position: relative; height: 100vh; background: #fbf9f6; }
position: relative; .banner-fixed { position: fixed; top: 0; left: 0; width: 100%; z-index: 0; }
height: 100vh; .page-scroll { position: relative; z-index: 1; height: 100%; }
background: #FAF8F5; .banner-spacer { height: 420rpx; }
}
/* Banner fixed behind everything */
.banner-fixed {
position: fixed;
top: 0;
left: 0;
width: 100%;
z-index: 0;
}
/* Scroll layer sits above banner */
.page-scroll {
position: relative;
z-index: 1;
flex: 1;
height: 100vh;
}
/* Transparent spacer lets banner peek through */
.banner-spacer {
height: 420rpx;
}
/* Floating card that overlaps the banner */
.floating-card { .floating-card {
position: relative; position: relative;
background: #FAF8F5;
border-radius: 32rpx 32rpx 0 0;
min-height: 100vh; min-height: 100vh;
padding-top: 12rpx; padding-top: 12rpx;
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.06); 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);
/* Small drag indicator at top of card */ -webkit-backdrop-filter: blur(16px);
.card-handle { backdrop-filter: blur(16px);
display: flex; box-shadow: 0 -4rpx 20rpx rgba(66, 54, 45, 0.06);
justify-content: center;
padding: 16rpx 0 8rpx;
}
.card-handle-bar {
width: 64rpx;
height: 8rpx;
border-radius: 4rpx;
background: rgba(0, 0, 0, 0.1);
}
.bottom-padding {
height: 120rpx;
} }
.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> </style>

View File

@@ -0,0 +1,45 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="日常建议" show-back />
<SafetyNotice v-if="report?.safety.flagged" :message="report.safety.message || ''" />
<view v-else-if="report?.advice">
<text class="title">给你的三个日常建议</text>
<view v-for="item in report.advice.items" :key="item.title" class="card">
<text class="name">{{ item.title }}</text>
<text class="body">{{ item.detail }}</text>
<text class="stop">如出现{{ item.stopIf }}</text>
</view>
</view>
<text class="disclaimer">这些建议用于日常活动参考不能替代现场评估</text>
<view class="cta" @tap="next">了解线上画像的局限</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { storeToRefs } from 'pinia'
import { onShow } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import SafetyNotice from '../../components/SafetyNotice.vue'
import { getSystemLayout } from '../../utils/system'
import { useBodyPortraitStore } from '../../stores/body-portrait'
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
const portrait = useBodyPortraitStore()
const { report } = storeToRefs(portrait)
onShow(() => { portrait.track('advice_viewed') })
function next() {
uni.navigateTo({ url: '/pages/portrait/trial' })
}
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #fbf9f6; padding: 0 40rpx 120rpx; }
.title { display: block; font-size: 40rpx; color: #4a4035; margin: 12rpx 0 24rpx; }
.card { background: #fff; border-radius: 24rpx; padding: 28rpx; margin-bottom: 20rpx; }
.name { display: block; font-size: 30rpx; color: #4a4035; }
.body, .stop, .disclaimer { display: block; margin-top: 12rpx; color: #7a6a5a; font-size: 26rpx; line-height: 1.7; }
.cta { margin-top: 32rpx; height: 92rpx; border-radius: 999rpx; background: #6b8276; color: #fff; display: flex; align-items: center; justify-content: center; }
</style>

View File

@@ -0,0 +1,156 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="身体状态评估" show-back />
<PortraitProgress :step="step + 1" :total="steps.length" />
<text class="q">{{ current.title }}</text>
<text class="d">{{ current.hint }}</text>
<BodySilhouette v-if="current.body" :selected="bodyValue" @change="onBody" />
<view v-else class="options">
<view
v-for="option in current.options"
:key="option.value"
class="opt"
:class="{ on: isOn(option.value) }"
@tap="pick(option.value)"
>
{{ option.label }}
</view>
</view>
<text class="disclaimer">非医疗诊断仅用于运动训练参考</text>
<view class="nav">
<view v-if="step > 0" class="ghost" @tap="step -= 1">上一步</view>
<view class="next" @tap="next">{{ step === steps.length - 1 ? '生成画像' : '继续' }}</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import BodySilhouette from '../../components/BodySilhouette.vue'
import PortraitProgress from '../../components/PortraitProgress.vue'
import { getSystemLayout } from '../../utils/system'
import { getErrorMessage } from '../../utils/auth'
import { useBodyPortraitStore } from '../../stores/body-portrait'
import {
AFTER_SITTING_LABELS,
AfterSitting,
BodyRegion,
EXERCISE_FREQ_LABELS,
ExerciseFreq,
PORTRAIT_GOAL_LABELS,
PortraitGoal,
SAFETY_FLAG_LABELS,
SafetyFlag,
SITTING_HOURS_LABELS,
SittingHours,
STANDING_NOTICE_LABELS,
StandingNotice,
WORK_POSTURE_LABELS,
WorkPosture,
} from '@mp-pilates/shared'
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
const portrait = useBodyPortraitStore()
const step = ref(0)
const steps = [
{ key: 'concerns', title: '最近身体哪里最困扰你?', hint: '可以多选,我们会据此调整后面的问题。', body: true },
{ key: 'goal', title: '如果训练有效果,你最希望看到什么变化?', hint: '后面的报告会用你自己的目标来写。', options: enumOptions(PORTRAIT_GOAL_LABELS) },
{ key: 'sittingHours', title: '每天坐多久?', hint: '这能帮助理解当前身体状态是怎么形成的。', options: enumOptions(SITTING_HOURS_LABELS) },
{ key: 'exerciseFreq', title: '平时运动频率?', hint: '没有对错,如实选择即可。', options: enumOptions(EXERCISE_FREQ_LABELS) },
{ key: 'workPosture', title: '工作时最常见状态?', hint: '选最常出现的一种。', options: enumOptions(WORK_POSTURE_LABELS) },
{ key: 'endOfDayFatigue', title: '一天结束后,身体哪里最累?', hint: '可以和困扰区域不同。', body: true },
{ key: 'afterSitting', title: '长时间坐着后,你会出现?', hint: '可多选。', options: enumOptions(AFTER_SITTING_LABELS), multi: true },
{ key: 'standingNotice', title: '自然站立时,你有没有注意过?', hint: '用日常语言描述,不需要专业判断。', options: enumOptions(STANDING_NOTICE_LABELS), multi: true },
{ key: 'safety', title: '最近是否存在以下情况?', hint: '这一题不计入关注度,只用于安全分流。', options: enumOptions(SAFETY_FLAG_LABELS), multi: true },
]
const current = computed(() => steps[step.value])
const bodyValue = computed(() => (current.value.key === 'concerns' ? portrait.answers.concerns : portrait.answers.endOfDayFatigue) as string[])
function enumOptions(labels: Record<string, string>) {
return Object.entries(labels).map(([value, label]) => ({ value, label }))
}
function isOn(value: string) {
const answers = portrait.answers as unknown as Record<string, unknown>
const currentValue = answers[current.value.key]
return Array.isArray(currentValue) ? currentValue.includes(value) : currentValue === value
}
async function persist(patch: Record<string, unknown>) {
await portrait.saveAnswers(patch as never)
}
async function onBody(value: BodyRegion[]) {
await persist({ [current.value.key]: value })
}
async function pick(value: string) {
if (current.value.multi) {
const answers = portrait.answers as unknown as Record<string, unknown>
const list = [...((answers[current.value.key] as string[]) || [])]
const exclusiveNone = current.value.key === 'safety' || current.value.key === 'afterSitting' || current.value.key === 'standingNotice'
if (value === 'none' || value === 'unnoticed') {
await persist({ [current.value.key]: [value] })
return
}
const next = list.includes(value) ? list.filter((item) => item !== value) : [...list.filter((item) => item !== 'none' && item !== 'unnoticed'), value]
if (exclusiveNone && !next.length) return
await persist({ [current.value.key]: next })
return
}
await persist({ [current.value.key]: value })
}
async function next() {
try {
if (step.value < steps.length - 1) {
step.value += 1
return
}
uni.showLoading({ title: '生成画像...' })
await portrait.complete()
uni.hideLoading()
uni.redirectTo({ url: '/pages/portrait/report' })
} catch (err) {
uni.hideLoading()
uni.showToast({ title: getErrorMessage(err, '请先完成必答题'), icon: 'none' })
}
}
onLoad(async () => {
try {
await portrait.ensureSession()
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '测评暂时无法开始'), icon: 'none' })
}
})
void AfterSitting
void ExerciseFreq
void PortraitGoal
void SafetyFlag
void SittingHours
void StandingNotice
void WorkPosture
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #fbf9f6; padding: 0 40rpx 160rpx; }
.q { display: block; font-size: 40rpx; line-height: 1.4; color: #4a4035; }
.d { display: block; margin: 16rpx 0 28rpx; color: #8a7b6e; font-size: 26rpx; line-height: 1.6; }
.options { display: flex; flex-direction: column; gap: 16rpx; }
.opt { padding: 28rpx; border-radius: 20rpx; background: #fff; color: #5d5148; font-size: 28rpx; }
.opt.on { background: #6b8276; color: #fff; }
.disclaimer { display: block; margin-top: 36rpx; color: #a09080; font-size: 22rpx; }
.nav { position: fixed; left: 0; right: 0; bottom: 0; padding: 24rpx 40rpx 48rpx; display: flex; gap: 16rpx; background: #fbf9f6; }
.ghost, .next { flex: 1; height: 88rpx; border-radius: 999rpx; display: flex; align-items: center; justify-content: center; }
.ghost { background: #efe8e1; color: #6b5c50; }
.next { background: #6b8276; color: #fff; }
</style>

View File

@@ -0,0 +1,50 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="身体状态评估" :show-back="false" />
<view class="hero">
<text class="kicker">3 分钟身体状态评估</text>
<text class="title">最近身体哪里最让你困扰</text>
<text class="lead">用一份对话式问卷生成你的身体画像不是医疗诊断只用于运动训练参考</text>
<text v-if="stats?.completedCount" class="count">已有 {{ stats.completedCount }} 人完成评估</text>
<view class="cta" @tap="start">开始测试</view>
</view>
<view class="trust">
<text>STOTT 认证教练</text>
<text>一对一专业评估</text>
<text>非医疗诊断仅用于运动训练参考</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { storeToRefs } from 'pinia'
import { onLoad } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { useBodyPortraitStore } from '../../stores/body-portrait'
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
const portrait = useBodyPortraitStore()
const { stats } = storeToRefs(portrait)
onLoad((query) => {
portrait.captureAttribution((query || {}) as Record<string, string>)
portrait.fetchStats().catch(() => {})
})
function start() {
uni.navigateTo({ url: '/pages/portrait/assessment' })
}
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #fbf9f6; padding: 0 40rpx 80rpx; }
.hero { padding-top: 48rpx; }
.kicker { display: block; color: #8a7b6e; font-size: 22rpx; letter-spacing: 3rpx; }
.title { display: block; margin-top: 20rpx; font-size: 48rpx; line-height: 1.35; color: #4a4035; }
.lead { display: block; margin-top: 20rpx; font-size: 28rpx; line-height: 1.7; color: #7a6a5a; }
.count { display: block; margin-top: 24rpx; color: #6b8276; font-size: 24rpx; }
.cta { margin-top: 48rpx; height: 96rpx; border-radius: 999rpx; background: #6b8276; color: #fff; display: flex; align-items: center; justify-content: center; font-size: 30rpx; }
.trust { margin-top: 56rpx; padding: 28rpx; border-radius: 24rpx; background: #fff; display: flex; flex-direction: column; gap: 12rpx; color: #7a6a5a; font-size: 24rpx; }
</style>

View File

@@ -0,0 +1,70 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="改善计划" show-back />
<view v-if="plan" class="card">
<text class="title">{{ plan.title }}</text>
<view v-for="phase in plan.phases" :key="phase.id" class="phase">
<text class="name">{{ phase.name }}</text>
<text class="meta"> {{ phase.lessonStart }}{{ phase.lessonEnd }} · {{ phase.focus.join(' / ') }}</text>
<text class="body">{{ phase.summary }}</text>
</view>
</view>
<view v-if="todos.length" class="card">
<text class="name">阶段复测</text>
<text v-for="todo in todos" :key="todo.id" class="body"> {{ todo.lessonCheckpoint }} {{ todo.completedAt ? '已完成' : '待安排' }}</text>
</view>
<view v-if="share" class="card">
<text class="title">{{ share.title }}</text>
<text class="body">{{ share.caption }}</text>
<text v-for="row in share.rows" :key="row.label" class="meta">{{ row.label }} {{ row.first === row.latest ? row.first : `${row.first} ${row.latest}` }}</text>
</view>
<view v-if="plan" class="cta" @tap="makeShare">生成我的成长卡片</view>
<view v-else class="body">完成到店评估后教练会为你生成 12 周改善计划</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onShow, onShareAppMessage } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { get, post } from '../../utils/request'
import { getErrorMessage } from '../../utils/auth'
import type { GrowthShareCardRecord, ReassessmentTodoRecord, TrainingPlanRecord } from '@mp-pilates/shared'
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
const plan = ref<TrainingPlanRecord | null>(null)
const todos = ref<ReassessmentTodoRecord[]>([])
const share = ref<GrowthShareCardRecord | null>(null)
onShow(async () => {
try {
const plans = await get<TrainingPlanRecord[]>('/body-portrait/plans')
plan.value = plans[0] || null
todos.value = await get<ReassessmentTodoRecord[]>('/body-portrait/todos')
} catch {}
})
async function makeShare() {
try {
share.value = await post<GrowthShareCardRecord>('/body-portrait/share-cards', { includePhotos: false })
uni.showToast({ title: '已生成,可分享给朋友', icon: 'none' })
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '暂时无法生成'), icon: 'none' })
}
}
onShareAppMessage(() => ({
title: share.value?.title || '我的普拉提变化',
path: share.value ? `/pages/portrait/index?source=member_share&shareCode=${share.value.shareCode}` : '/pages/portrait/index?source=member_share',
}))
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #fbf9f6; padding: 0 40rpx 80rpx; }
.card { background: #fff; border-radius: 24rpx; padding: 28rpx; margin-bottom: 20rpx; }
.title { display: block; font-size: 36rpx; color: #4a4035; }
.name { display: block; margin-top: 16rpx; font-size: 30rpx; color: #4a4035; }
.meta, .body { display: block; margin-top: 10rpx; color: #7a6a5a; font-size: 26rpx; line-height: 1.6; }
.cta { height: 92rpx; border-radius: 999rpx; background: #6b8276; color: #fff; display: flex; align-items: center; justify-content: center; }
</style>

View File

@@ -0,0 +1,75 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="你的身体画像" show-back />
<view v-if="teaser" class="card">
<text class="kicker">你的身体画像</text>
<text class="title">{{ teaser.headline }}</text>
<text class="summary">{{ claimed && report ? report.summary : '登录后查看完整个性化报告。问卷画像只能反映生活习惯和主观感受。' }}</text>
<text class="match">问卷依据{{ teaser.matchQuality === 'full' ? '充分' : '一般' }}这表示回答是否足够形成解释不是诊断准确率</text>
</view>
<SafetyNotice v-if="report?.safety.flagged" :message="report.safety.message || ''" />
<PortraitRadar v-if="claimed && report" :scores="report.scores" />
<view v-if="claimed && report" class="block">
<view v-for="(item, index) in report.evidence" :key="item.title" class="evidence">
<text class="etitle">{{ index + 1 }} {{ item.title }}</text>
<text class="eans">根据你的答案{{ item.answers.join('、') }}</text>
<text class="ebody">{{ item.explanation }}</text>
</view>
</view>
<view v-if="claimed && report" class="block">
<text class="etitle">这些问题是有关联的</text>
<text v-for="stepItem in report.chain.steps" :key="stepItem" class="chain">{{ stepItem }}</text>
<text class="ebody">{{ report.chain.takeaway }}</text>
</view>
<text class="disclaimer">非医疗诊断仅用于运动训练参考</text>
<view class="cta" @tap="continueFlow">{{ claimed ? '查看建议' : '登录查看完整报告' }}</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { storeToRefs } from 'pinia'
import { onShow } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import PortraitRadar from '../../components/PortraitRadar.vue'
import SafetyNotice from '../../components/SafetyNotice.vue'
import { getSystemLayout } from '../../utils/system'
import { getErrorMessage } from '../../utils/auth'
import { useBodyPortraitStore } from '../../stores/body-portrait'
import { useUserStore } from '../../stores/user'
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
const portrait = useBodyPortraitStore()
const userStore = useUserStore()
const { teaser, report, claimed } = storeToRefs(portrait)
onShow(async () => {
if (!teaser.value && !portrait.session) {
await portrait.loadMine().catch(() => {})
}
if (teaser.value) await portrait.track('report_viewed')
})
async function continueFlow() {
try {
if (!claimed.value) {
if (!userStore.loggedIn) await userStore.login()
await portrait.claim()
}
uni.navigateTo({ url: '/pages/portrait/advice' })
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '请先登录后查看完整报告'), icon: 'none' })
}
}
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #fbf9f6; padding: 0 40rpx 120rpx; }
.card, .block, .evidence { background: #fff; border-radius: 24rpx; padding: 32rpx; margin-bottom: 24rpx; }
.kicker { color: #8a7b6e; font-size: 22rpx; }
.title { display: block; margin-top: 12rpx; font-size: 40rpx; color: #4a4035; line-height: 1.4; }
.summary, .match, .ebody, .eans, .chain { display: block; margin-top: 16rpx; color: #7a6a5a; font-size: 26rpx; line-height: 1.7; }
.etitle { display: block; font-size: 30rpx; color: #4a4035; }
.disclaimer { display: block; color: #a09080; font-size: 22rpx; margin: 12rpx 0 24rpx; }
.cta { height: 92rpx; border-radius: 999rpx; background: #6b8276; color: #fff; display: flex; align-items: center; justify-content: center; }
</style>

View File

@@ -0,0 +1,68 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="到店评估" show-back />
<text class="title">线上画像只能看到一部分</text>
<text class="lead">问卷能够帮助我们了解你的生活习惯和主观感受但身体真正如何运动需要通过现场观察进一步判断</text>
<view class="card">
<text class="name">到店专业评估会进一步看</text>
<text class="row">静态体态脊柱骨盆</text>
<text class="row">呼吸活动度稳定性与基础动作</text>
<text class="row">明确个人训练重点</text>
</view>
<view v-if="report?.safety.flagged" class="card">
<text class="name">当前不适合直接预约体验训练</text>
<text class="lead">建议先确认运动条件你也可以联系工作室我们会帮你判断下一步</text>
</view>
<button v-else-if="!userStore.user?.phone" class="cta" open-type="getPhoneNumber" @getphonenumber="book">预约身体评估体验</button>
<view v-else class="cta" @tap="goTrial">预约身体评估体验</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { storeToRefs } from 'pinia'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { getErrorMessage, wxBindPhone } from '../../utils/auth'
import { useBodyPortraitStore } from '../../stores/body-portrait'
import { useUserStore } from '../../stores/user'
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
const portrait = useBodyPortraitStore()
const userStore = useUserStore()
const { report } = storeToRefs(portrait)
async function goTrial() {
try {
if (!userStore.loggedIn) await userStore.login()
await portrait.track('trial_clicked')
uni.navigateTo({ url: '/pages/card/detail?trial=1&fromPortrait=1' })
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '请先登录'), icon: 'none' })
}
}
async function book(e: { detail: { encryptedData: string; iv: string; errMsg: string } }) {
try {
if (!userStore.loggedIn) await userStore.login()
if (e.detail.errMsg !== 'getPhoneNumber:ok') {
uni.showToast({ title: '预约体验需要授权手机号', icon: 'none' })
return
}
await wxBindPhone(e as Parameters<typeof wxBindPhone>[0])
await userStore.fetchProfile()
await goTrial()
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '请先授权手机号'), icon: 'none' })
}
}
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #fbf9f6; padding: 0 40rpx 80rpx; }
.title { display: block; font-size: 40rpx; color: #4a4035; margin-top: 12rpx; }
.lead, .row { display: block; margin-top: 16rpx; color: #7a6a5a; font-size: 26rpx; line-height: 1.7; }
.card { margin-top: 28rpx; background: #fff; border-radius: 24rpx; padding: 28rpx; }
.name { display: block; font-size: 30rpx; color: #4a4035; }
.cta { margin-top: 48rpx; height: 92rpx; border-radius: 999rpx; background: #6b8276; color: #fff; font-size: 30rpx; display: flex; align-items: center; justify-content: center; }
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,44 +1,67 @@
<template> <template>
<view class="profile-page"> <view class="profile-page" :style="{ paddingTop: navBarHeight + 'px' }">
<!-- Custom nav bar (transparent, blends with UserCard gradient) --> <view class="profile-page__nav" :style="{ paddingTop: statusBarHeight + 'px' }">
<CustomNavBar title="我的" transparent /> <view class="profile-page__nav-title">我的</view>
</view>
<!-- User card --> <!-- User card -->
<UserCard :logged-in="loggedIn" :has-profile="hasProfile" :user="user" :stats="stats" :memberships="memberships" <UserCard :logged-in="loggedIn" :has-profile="hasProfile" :user="user" :memberships="memberships" :now="membershipNow"
:loading="loginLoading" :nav-bar-height="navBarHeight" @login="handleLogin" /> :memberships-loading="membershipsLoading" :memberships-loaded="membershipsLoaded" :memberships-error="membershipsError"
:loading="loginLoading" @login="handleLogin" @edit="goToInfo" @refresh-memberships="userStore.fetchMemberships()" />
<InviteCard />
<!-- Menu section: always visible --> <!-- Menu section: always visible -->
<ProfileMenu :is-admin="isAdmin" :require-auth="loggedIn" @clear-cache="handleClearCache" @about="handleAbout" <ProfileMenu
@require-login="handleLogin" /> :is-admin="isAdmin"
:require-auth="loggedIn"
@clear-cache="handleClearCache"
@require-login="handleLogin"
@open-notifications="showNotificationsModal = true"
>
<PracticeActivityCard v-if="loggedIn" :key="userStore.token" :refresh-key="activityRefreshKey" />
</ProfileMenu>
<!-- Logout button: only when logged in --> <!-- Logout button: only when logged in -->
<view v-if="loggedIn" class="profile-page__logout-wrap"> <view v-if="loggedIn" class="profile-page__logout-wrap">
<button class="profile-page__logout-btn" @tap="handleLogout">退出登录</button> <button class="profile-page__logout-btn" @tap="handleLogout">退出登录</button>
</view> </view>
<!-- Notification Settings Modal -->
<SubscriptionSettingsModal v-model:visible="showNotificationsModal" />
</view> </view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import InviteCard from '../../components/InviteCard.vue'
import { useInviteStore } from '../../stores/invite'
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app' import { onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useUserStore } from '../../stores/user' import { useUserStore } from '../../stores/user'
import { getSystemLayout } from '../../utils/system' import { getSystemLayout } from '../../utils/system'
import { getErrorMessage } from '../../utils/auth'
import PracticeActivityCard from '../../components/PracticeActivityCard.vue'
import UserCard from '../../components/UserCard.vue' import UserCard from '../../components/UserCard.vue'
import ProfileMenu from '../../components/ProfileMenu.vue' import ProfileMenu from '../../components/ProfileMenu.vue'
import CustomNavBar from '../../components/CustomNavBar.vue' import SubscriptionSettingsModal from '../../components/SubscriptionSettingsModal.vue'
const invite = useInviteStore()
const userStore = useUserStore() const userStore = useUserStore()
const { loggedIn, hasProfile, user, stats, memberships, isAdmin } = storeToRefs(userStore) const { loggedIn, hasProfile, user, memberships, membershipsLoading, membershipsLoaded, membershipsError, isAdmin } = storeToRefs(userStore)
const showNotificationsModal = ref(false)
const activityRefreshKey = ref(0)
const membershipNow = ref(Date.now())
const loginLoading = ref(false) const loginLoading = ref(false)
const navBarHeight = ref(64) const navBarHeight = ref(getSystemLayout().navBarHeight)
const statusBarHeight = getSystemLayout().statusBarHeight
// ─── 微信分享 ─────────────────────────────────────────────── // ─── 微信分享 ───────────────────────────────────────────────
onShareAppMessage(() => { onShareAppMessage(() => {
return { return {
title: '我的普拉提会所,记录每一次进步', title: invite.code ? '送你 95 折购卡礼,一起练普拉提' : '一起练普拉提',
path: '/pages/profile/index', path: invite.code ? `/pages/card/detail?showAll=1&inviteCode=${invite.code}` : '/pages/home/index',
imageUrl: '', imageUrl: '',
} }
}) })
@@ -55,10 +78,13 @@ onMounted(() => {
}) })
onShow(async () => { onShow(async () => {
membershipNow.value = Date.now()
activityRefreshKey.value += 1
if (loggedIn.value) { if (loggedIn.value) {
await Promise.all([ await Promise.all([
invite.refresh().catch(() => {}),
invite.refreshActivity().catch(() => {}),
userStore.fetchProfile(), userStore.fetchProfile(),
userStore.fetchStats(),
userStore.fetchMemberships(), userStore.fetchMemberships(),
]) ])
} }
@@ -70,21 +96,25 @@ async function handleLogin() {
try { try {
const { isNewUser } = await userStore.loginWithSetup() const { isNewUser } = await userStore.loginWithSetup()
if (!isNewUser) { if (!isNewUser) {
await userStore.fetchStats() await invite.refreshActivity().catch(() => {})
} }
} catch { } catch (err: unknown) {
uni.showToast({ title: '登录失败,请重试', icon: 'none' }) uni.showToast({ title: getErrorMessage(err, '登录失败,请重试'), icon: 'none' })
} finally { } finally {
loginLoading.value = false loginLoading.value = false
} }
} }
function goToInfo() {
uni.navigateTo({ url: '/pages/profile/info' })
}
function handleLogout() { function handleLogout() {
uni.showModal({ uni.showModal({
title: '退出登录', title: '退出登录',
content: '确定要退出登录吗?', content: '确定要退出登录吗?',
confirmText: '退出', confirmText: '退出',
confirmColor: '#ff4d4f', confirmColor: '#9c7a6e',
success(res) { success(res) {
if (res.confirm) { if (res.confirm) {
userStore.logout() userStore.logout()
@@ -106,38 +136,14 @@ function handleClearCache() {
}) })
} }
function handleAbout() {
uni.showModal({
title: '关于我们',
content: 'Focus Core 普拉提工作室\n版本 1.0.0\n\n专注核心遇见更好的自己',
showCancel: false,
})
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.profile-page { .profile-page {
min-height: 100vh; min-height: 100vh; box-sizing: border-box; background: #fbf9f6; padding-bottom: 36rpx;
&__nav { position: fixed; top: 0; left: 0; right: 0; z-index: 101; background: #fbf9f6; }
&__logout-wrap { &__nav-title { height: 88rpx; display: flex; align-items: center; justify-content: center; font-size: 34rpx; font-weight: 500; color: #514943; }
margin: $spacing-xl $spacing-lg $spacing-xl; &__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; } }
&__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;
}
}
} }
</style> </style>

View File

@@ -1,112 +1,62 @@
<template> <template>
<view class="info-page" :style="{ paddingTop: navBarHeight }"> <view class="info-page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar <CustomNavBar :title="isFromLogin ? '完善个人信息' : '个人信息'" :show-back="!isFromLogin" />
:title="isFromLogin ? '完善个人信息' : '个人信息'"
:show-back="!isFromLogin"
/>
<!-- First-login welcome banner -->
<view v-if="isFromLogin" class="welcome-banner"> <view v-if="isFromLogin" class="welcome-banner">
<view class="welcome-content"> <text class="welcome-title">欢迎来到工作室</text>
<view class="welcome-text"> <text class="welcome-desc">设置头像和昵称方便老师认识你</text>
<text class="welcome-title">欢迎加入</text>
<text class="welcome-desc">设置你的头像和昵称让大家认识你</text>
</view>
</view>
</view> </view>
<!-- Avatar section --> <view class="avatar-section">
<view class="avatar-section" :class="{ 'avatar-section--welcome': isFromLogin }"> <button class="avatar-btn" open-type="chooseAvatar" :disabled="busy" @chooseavatar="handleChooseAvatar">
<button class="avatar-btn" open-type="chooseAvatar" @chooseavatar="handleChooseAvatar">
<view class="avatar-wrap"> <view class="avatar-wrap">
<image <image v-if="displayAvatarUrl" class="avatar" :src="displayAvatarUrl" mode="aspectFill" @error="avatarFailed = true" />
v-if="displayAvatarUrl" <view v-else class="avatar-placeholder"><text>{{ nicknameInitial }}</text></view>
class="avatar"
:src="displayAvatarUrl"
mode="aspectFill"
/>
<view v-else class="avatar-placeholder">
<text class="avatar-placeholder-text">{{ nicknameInitial }}</text>
</view>
<!-- Upload hint overlay -->
<view class="avatar-overlay">
<text class="avatar-overlay-text">点击更换</text>
</view>
</view> </view>
<view class="avatar-copy">
<text class="avatar-title">头像</text>
<text class="avatar-action">{{ uploadingAvatar ? '正在更新…' : '点击更换' }}</text>
</view>
<text class="avatar-arrow"></text>
</button> </button>
<text class="avatar-name">{{ form.nickname || '未设置昵称' }}</text> <text class="avatar-hint">头像更换后自动保存</text>
<text class="avatar-hint">点击头像选择微信头像</text>
</view> </view>
<!-- Form fields --> <view class="form-section">
<view class="form-card"> <text class="section-title">基本资料</text>
<!-- Nickname (editable) --> <view class="form-card">
<view class="form-row"> <view class="nickname-field">
<text class="form-label">昵称</text> <view class="field-heading">
<input <text class="form-label">昵称</text>
class="form-input" <text class="field-count">{{ form.nickname.length }} / 20</text>
type="nickname" </view>
v-model="form.nickname" <input class="form-input" type="nickname" v-model="form.nickname" placeholder="输入昵称,或选择微信昵称"
placeholder="点击右侧按钮获取微信昵称" placeholder-style="color: #a2978e" maxlength="20" :disabled="busy" :cursor-spacing="24" />
placeholder-style="color: #ccc" </view>
maxlength="20" <view v-if="!isFromLogin" class="form-row">
:disabled="saving" <text class="form-label">手机号</text>
/> <text v-if="hasPhone" class="form-value">{{ phoneDisplay }}</text>
<text class="form-arrow"></text> <button v-else class="bind-phone-btn" open-type="getPhoneNumber" :disabled="busy" :loading="bindingPhone" @getphonenumber="handleGetPhone">绑定手机号</button>
</view> </view>
<!-- Phone (hide in first-login mode) -->
<view v-if="!isFromLogin" class="form-row form-row--last">
<text class="form-label">手机号</text>
<!-- Phone set: display masked -->
<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> </view>
</view> </view>
<!-- Read-only info card (hide in first-login mode) -->
<view v-if="!isFromLogin" class="info-card"> <view v-if="!isFromLogin" class="info-card">
<view class="info-row"> <view class="info-row"><text class="info-label">注册时间</text><text class="info-value">{{ joinDateDisplay }}</text></view>
<text class="info-label">注册时间</text> <view class="info-row"><text class="info-label">有效会员卡</text><text class="info-value">{{ activeMembershipCount }} </text></view>
<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> </view>
<!-- Save button -->
<view class="save-wrap"> <view class="save-wrap">
<view <button class="save-btn" :disabled="saveDisabled" :loading="saving" @tap="handleSave">
class="save-btn" {{ saving ? '保存中' : isFromLogin ? '保存并进入' : '保存修改' }}
:class="{ </button>
'save-btn--loading': saving, <text v-if="!isFromLogin" class="save-hint">{{ isDirty ? '昵称修改后请点击保存' : '修改昵称后即可保存' }}</text>
'save-btn--disabled': !isFromLogin && (!isDirty || saving), <button v-if="isFromLogin" class="skip-btn" :disabled="busy" @tap="handleSkip">稍后再说</button>
}"
@tap="handleSave"
>
<text class="save-btn-text">
{{ saving ? '保存中...' : isFromLogin ? '保存并进入' : '保存修改' }}
</text>
</view>
<text v-if="isFromLogin" class="skip-text" @tap="handleSkip">稍后再说</text>
</view> </view>
</view> </view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import { onLoad } from '@dcloudio/uni-app' import { onLoad } from '@dcloudio/uni-app'
import { useUserStore } from '../../stores/user' import { useUserStore } from '../../stores/user'
import { wxBindPhone } from '../../utils/auth' import { wxBindPhone } from '../../utils/auth'
@@ -125,7 +75,7 @@ onLoad((query) => {
}) })
// ─── Nav bar height ────────────────────────────────────── // ─── Nav bar height ──────────────────────────────────────
const navBarHeight = ref('64px') const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
// ─── Form state ─────────────────────────────────────────── // ─── Form state ───────────────────────────────────────────
const form = ref({ const form = ref({
@@ -134,6 +84,11 @@ const form = ref({
const originalNickname = ref('') const originalNickname = ref('')
const saving = ref(false) const saving = ref(false)
const uploadingAvatar = 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 ───────────────────────────────────────────── // ─── Computed ─────────────────────────────────────────────
const isDirty = computed(() => form.value.nickname.trim() !== originalNickname.value) const isDirty = computed(() => form.value.nickname.trim() !== originalNickname.value)
@@ -165,26 +120,19 @@ const activeMembershipCount = computed(
() => userStore.user?.activeMembershipCount ?? userStore.activeMemberships.length, () => userStore.user?.activeMembershipCount ?? userStore.activeMemberships.length,
) )
// ─── Default avatar ─────────────────────────────────────── // Use a local fallback when no avatar is set or the remote image fails.
const defaultAvatarUrl = computed(() => { const displayAvatarUrl = computed(() => avatarFailed.value ? '' : avatarUrl.value)
const nickname = form.value.nickname || 'user' watch(avatarUrl, () => { avatarFailed.value = false })
// 使用 dicebear 生成基于昵称的随机头像
return `https://api.dicebear.com/7.x/identicon/svg?seed=${encodeURIComponent(nickname)}&backgroundColor=c9a87c,e8c88a`
})
const displayAvatarUrl = computed(() => {
return avatarUrl.value || defaultAvatarUrl.value
})
// ─── Avatar upload ──────────────────────────────────────── // ─── Avatar upload ────────────────────────────────────────
async function handleChooseAvatar(e: { detail: { avatarUrl: string } }) { async function handleChooseAvatar(e: { detail: { avatarUrl: string } }) {
const { avatarUrl } = e.detail const { avatarUrl } = e.detail
if (!avatarUrl) return if (!avatarUrl || busy.value) return
uploadingAvatar.value = true uploadingAvatar.value = true
try { try {
await userStore.updateProfile({ avatarUrl }) await userStore.updateProfile({ avatarUrl })
await userStore.fetchProfile() avatarFailed.value = false
uni.showToast({ title: '头像更新成功', icon: 'success' }) uni.showToast({ title: '头像更新成功', icon: 'success' })
} catch (err: unknown) { } catch (err: unknown) {
const msg = err instanceof Error ? err.message : '更新失败,请重试' const msg = err instanceof Error ? err.message : '更新失败,请重试'
@@ -198,32 +146,32 @@ async function handleChooseAvatar(e: { detail: { avatarUrl: string } }) {
async function handleGetPhone(e: { async function handleGetPhone(e: {
detail: { encryptedData: string; iv: string; errMsg: string } detail: { encryptedData: string; iv: string; errMsg: string }
}) { }) {
if (busy.value) return
if (e.detail.errMsg !== 'getPhoneNumber:ok') { if (e.detail.errMsg !== 'getPhoneNumber:ok') {
// User denied or cancelled // User denied or cancelled
return return
} }
bindingPhone.value = true
uni.showLoading({ title: '绑定中...' }) uni.showLoading({ title: '绑定中...' })
try { try {
const updated = await wxBindPhone(e as Parameters<typeof wxBindPhone>[0]) await wxBindPhone(e as Parameters<typeof wxBindPhone>[0])
// Refresh store with updated profile // Refresh store with updated profile
await userStore.fetchProfile() await userStore.fetchProfile()
uni.hideLoading() uni.hideLoading()
uni.showToast({ title: '手机号绑定成功', icon: 'success' }) uni.showToast({ title: '手机号绑定成功', icon: 'success' })
// Sync nickname from updated profile // Keep the local nickname draft while refreshing the phone information.
if (updated.nickname) {
form.value = { nickname: updated.nickname }
originalNickname.value = updated.nickname
}
} catch (err: unknown) { } catch (err: unknown) {
uni.hideLoading() uni.hideLoading()
const msg = err instanceof Error ? err.message : '绑定失败,请重试' const msg = err instanceof Error ? err.message : '绑定失败,请重试'
uni.showToast({ title: msg, icon: 'none' }) uni.showToast({ title: msg, icon: 'none' })
} finally {
bindingPhone.value = false
} }
} }
// ─── Save ───────────────────────────────────────────────── // ─── Save ─────────────────────────────────────────────────
async function handleSave() { async function handleSave() {
if (saving.value) return if (busy.value) return
// In first-login mode, allow saving even if not dirty (user may just want to proceed) // In first-login mode, allow saving even if not dirty (user may just want to proceed)
if (!isFromLogin.value && !isDirty.value) return if (!isFromLogin.value && !isDirty.value) return
@@ -262,6 +210,7 @@ async function handleSave() {
// ─── Skip (first-login only) ───────────────────────────── // ─── Skip (first-login only) ─────────────────────────────
function handleSkip() { function handleSkip() {
if (busy.value) return
uni.showModal({ uni.showModal({
title: '确认跳过?', title: '确认跳过?',
content: '完善头像和昵称可以让教练和伙伴更容易认识你', content: '完善头像和昵称可以让教练和伙伴更容易认识你',
@@ -278,312 +227,58 @@ function handleSkip() {
// ─── Lifecycle ──────────────────────────────────────────── // ─── Lifecycle ────────────────────────────────────────────
onMounted(async () => { onMounted(async () => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px` navBarHeight.value = `${getSystemLayout().navBarHeight}px`
if (!isFromLogin.value) { try {
await userStore.fetchProfile() if (!isFromLogin.value) {
} await userStore.fetchProfile()
if (userStore.user) { }
form.value = { nickname: userStore.user.nickname } if (userStore.user) {
originalNickname.value = userStore.user.nickname form.value = { nickname: userStore.user.nickname }
originalNickname.value = userStore.user.nickname
}
} finally {
initializing.value = false
} }
}) })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.info-page { .info-page { min-height: 100vh; box-sizing: border-box; padding-bottom: calc(32rpx + env(safe-area-inset-bottom)); background: #fbf9f6; color: #514943; }
min-height: 100vh; .welcome-banner { margin: 28rpx 32rpx 0; }
background: $bg-page; .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; }
/* ── Welcome banner (first-login) ───────────────────── */
.welcome-banner {
position: relative;
margin: 0 $spacing-lg $spacing-md;
padding: 36rpx 32rpx;
background: linear-gradient(135deg, $brand-color 0%, #6b5d52 100%);
border-radius: $radius-lg;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: -40rpx;
right: -20rpx;
width: 180rpx;
height: 180rpx;
background: radial-gradient(circle, rgba(255, 255, 255, 0.15) 0%, transparent 70%);
pointer-events: none;
}
}
.welcome-content {
position: relative;
z-index: 1;
}
.welcome-text {
display: flex;
flex-direction: column;
gap: 6rpx;
}
.welcome-title {
font-size: 34rpx;
font-weight: 700;
color: #ffffff;
letter-spacing: 2rpx;
}
.welcome-desc {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.75);
line-height: 1.5;
}
/* ── Avatar section ──────────────────────────────────── */
.avatar-section {
display: flex;
flex-direction: column;
align-items: center;
padding: 56rpx 0 40rpx;
background: $bg-card;
margin-bottom: $spacing-md;
border-bottom: 1rpx solid $border-color;
&--welcome {
padding-top: 40rpx;
}
}
.avatar-btn { .avatar-btn {
background: transparent; display: flex; align-items: center; gap: 24rpx; width: 100%; min-width: 0;
border: none; height: auto; margin: 0; padding: 0; border: none; background: transparent;
padding: 0; border-radius: 0; line-height: 1.4; text-align: left; color: #514943;
margin: 0; &::after { border: none; }
&[disabled] { background: transparent; color: #8b817b; }
&::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 $border-color;
}
.avatar-placeholder {
width: 160rpx;
height: 160rpx;
border-radius: 50%;
background: linear-gradient(135deg, $brand-color, $accent-color);
display: flex;
align-items: center;
justify-content: center;
}
.avatar-placeholder-text {
font-size: 64rpx;
font-weight: 700;
color: #fff;
}
.avatar-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 52rpx;
background: rgba(0, 0, 0, 0.45);
border-radius: 0 0 80rpx 80rpx;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.85;
}
.avatar-overlay-text {
font-size: 20rpx;
color: #ffffff;
letter-spacing: 1rpx;
}
.avatar-name {
font-size: 34rpx;
font-weight: 700;
color: $text-primary;
margin-bottom: 6rpx;
}
.avatar-hint {
font-size: 22rpx;
color: $text-hint;
}
/* ── Form card ───────────────────────────────────────── */
.form-card {
background: $bg-card;
border-radius: $radius-lg;
margin: 0 $spacing-lg $spacing-md;
overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
}
.form-row {
display: flex;
flex-direction: row;
align-items: center;
padding: 32rpx 28rpx;
border-bottom: 1rpx solid rgba($border-color, 0.5);
min-height: 100rpx;
&--last {
border-bottom: none;
}
}
.form-label {
font-size: 28rpx;
color: $text-secondary;
width: 120rpx;
flex-shrink: 0;
font-weight: 500;
}
.form-input {
flex: 1;
font-size: 28rpx;
color: $text-primary;
text-align: right;
background: transparent;
min-height: 44rpx;
}
.form-value {
flex: 1;
font-size: 28rpx;
color: $text-hint;
text-align: right;
}
.form-arrow {
font-size: 36rpx;
color: $text-hint;
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: $accent-color;
font-weight: 600;
text-decoration: underline;
}
/* ── Read-only info card ──────────────────────────────── */
.info-card {
background: $bg-card;
border-radius: $radius-lg;
margin: 0 $spacing-lg $spacing-lg;
overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
}
.info-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 28rpx 28rpx;
border-bottom: 1rpx solid rgba($border-color, 0.5);
&--last {
border-bottom: none;
}
}
.info-label {
font-size: 26rpx;
color: $text-hint;
}
.info-value {
font-size: 26rpx;
color: $text-secondary;
font-weight: 500;
}
/* ── Save button ─────────────────────────────────────── */
.save-wrap {
padding: 8rpx $spacing-lg 48rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 24rpx;
}
.save-btn {
width: 100%;
height: 96rpx;
border-radius: 48rpx;
background: linear-gradient(135deg, $brand-color, #5e5045);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 24rpx rgba($brand-color, 0.25);
transition: all 0.25s ease;
&:active {
transform: scale(0.98);
box-shadow: 0 4rpx 12rpx rgba($brand-color, 0.2);
}
&--loading,
&--disabled {
opacity: 0.45;
box-shadow: none;
}
}
.save-btn-text {
font-size: 32rpx;
font-weight: 700;
color: #ffffff;
letter-spacing: 2rpx;
}
.skip-text {
font-size: 26rpx;
color: $text-hint;
padding: 8rpx 24rpx;
&:active {
opacity: 0.6;
}
} }
.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> </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,165 +1,99 @@
<template> <template>
<view class="membership-page" :style="{ paddingTop: navBarHeight }"> <view class="membership-page" :style="{ paddingTop: navBarHeight, height: pageHeight }">
<CustomNavBar title="我的会员卡" show-back /> <CustomNavBar title="我的会员卡" show-back />
<!-- Pull-to-refresh scroll view --> <scroll-view class="scroll" scroll-y refresher-enabled :refresher-triggered="refreshing" @refresherrefresh="onRefresh">
<scroll-view <view v-if="userStore.loggedIn" class="practice-summary">
class="scroll" <view class="summary-heading"><text class="summary-kicker">MY PRACTICE</text><text class="summary-title">每一次练习都在积累</text></view>
scroll-y <view class="summary-grid">
refresher-enabled <view><text class="summary-value">{{ userStore.statsError ? '—' : userStore.stats?.totalBookings ?? '—' }}</text><text class="summary-label">累计上课 · </text></view>
:refresher-triggered="refreshing" <view><text class="summary-value">{{ userStore.statsError ? '—' : userStore.stats?.monthBookings ?? '—' }}</text><text class="summary-label">本月上课 · </text></view>
@refresherrefresh="onRefresh" <view><text class="summary-value">{{ remainingLabel }}</text><text class="summary-label">剩余课时{{ finiteBalance > 0 || !unlimitedCount ? ' · 次' : '' }}</text></view>
> </view>
<!-- Loading skeleton --> <text v-if="unlimitedCount && finiteBalance > 0 && !userStore.membershipsError" class="summary-note">另有 {{ unlimitedCount }} 张有效不限次卡可在有效期内预约</text>
<view v-if="loading && !refreshing" class="loading-wrap"> <button v-if="userStore.statsError" class="summary-retry" @tap="userStore.fetchStats()">练习统计暂时无法更新点击重试 </button>
</view>
<view v-if="!userStore.loggedIn" class="empty-wrap"><view class="empty-card"><text class="empty-title">登录后查看会员卡</text><button class="empty-btn" @tap="goProfile">前往个人中心</button></view></view>
<view v-else-if="userStore.membershipsError" class="empty-wrap"><view class="empty-card"><text class="empty-title">会员卡暂时未能更新</text><text class="empty-sub">请重试后查看最新余额和有效期。</text><button class="empty-btn" @tap="loadMemberships">重新加载</button></view></view>
<view v-else-if="loading && !refreshing && !allMemberships.length" class="loading-wrap">
<view v-for="i in 2" :key="i" class="skeleton-card" /> <view v-for="i in 2" :key="i" class="skeleton-card" />
</view> </view>
<!-- Empty state -->
<view v-else-if="allMemberships.length === 0" class="empty-wrap"> <view v-else-if="allMemberships.length === 0" class="empty-wrap">
<text class="empty-icon">💳</text> <view class="empty-card">
<text class="empty-title">暂无会员卡</text> <text class="empty-title">还没有会员卡</text>
<text class="empty-sub">购买会员卡后即可预约课程</text> <text class="empty-sub">选一张适合自己的卡开始第一次练习</text>
<view class="empty-btn" @tap="goStore"> <button class="empty-btn" @tap="goStore">选购会员卡</button>
<text class="empty-btn-text">去购买</text>
</view> </view>
</view> </view>
<!-- Membership list -->
<view v-else class="list"> <view v-else class="list">
<!-- Active cards --> <view v-if="activeMemberships.length" class="group-section">
<view v-if="activeMemberships.length > 0" class="group-section">
<view class="group-header"> <view class="group-header">
<view class="group-dot group-dot--active" /> <text class="group-title">正在使用</text>
<text class="group-title">有效会员</text> <text class="group-count">{{ activeMemberships.length }} 张有效</text>
<text class="group-count">{{ activeMemberships.length }} </text>
</view> </view>
<view v-for="m in activeMemberships" :key="m.id" class="owned-card-wrap">
<view <OwnedMembershipCard :membership="m" :now="membershipNow">
v-for="m in activeMemberships" <view class="mc-actions">
:key="m.id" <button v-if="canRenewMembership(m)" class="mc-renew" @tap="goRenew(m)">续卡</button>
class="card-item" <button class="mc-book" @tap="goBooking">预约课程</button>
>
<!-- 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">{{ getCardTypeLabel(m.cardType.type) }}</text>
</view>
</view> </view>
<view class="status-badge status-badge--active"> </OwnedMembershipCard>
<text class="status-badge-text">有效</text>
</view>
</view>
<!-- Card body -->
<view class="card-body">
<!-- Times card: remaining times + progress -->
<template v-if="m.remainingTimes !== null">
<view class="highlight-row">
<text class="highlight-label">剩余课时</text>
<text class="highlight-value">
<text class="highlight-number">{{ m.remainingTimes }}</text>
<text class="highlight-unit"> </text>
</text>
</view>
<view v-if="m.cardType.totalTimes" class="progress-wrap">
<view class="progress-bar">
<view
class="progress-fill"
:style="{ width: getMembershipProgressWidth(m) }"
/>
</view>
<text class="progress-label">
已使用 {{ getMembershipUsedTimes(m) }} / {{ m.cardType.totalTimes }}
</text>
</view>
</template>
<!-- Duration card: expiry -->
<view class="info-row">
<text class="info-label">有效期至</text>
<text class="info-value">{{ m.expireDate.slice(0, 10) }}</text>
</view>
<view class="info-row">
<text class="info-label">开始日期</text>
<text class="info-value">{{ m.startDate.slice(0, 10) }}</text>
</view>
</view>
</view> </view>
<text class="usage-note">已用次数包含预约扣次不等同于已完成上课进度条表示已用次数占比</text>
</view> </view>
<!-- Expired / used up cards --> <view v-if="inactiveMemberships.length" class="group-section">
<view v-if="inactiveMemberships.length > 0" class="group-section">
<view class="group-header"> <view class="group-header">
<view class="group-dot group-dot--inactive" />
<text class="group-title">历史记录</text> <text class="group-title">历史记录</text>
<text class="group-count">{{ inactiveMemberships.length }} </text> <text class="group-count">{{ inactiveMemberships.length }} </text>
</view> </view>
<view v-for="m in inactiveMemberships" :key="m.id" class="mc mc--inactive">
<view <view class="mc-top">
v-for="m in inactiveMemberships" <view class="mc-name-area">
:key="m.id" <text class="mc-name">{{ m.cardType.name }}</text>
class="card-item card-item--inactive" <text class="mc-type-text">{{ getCardTypeLabel(m.cardType.type) }}</text>
>
<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">{{ getCardTypeLabel(m.cardType.type) }}</text>
</view>
</view>
<view class="status-badge" :class="statusBadgeClass(m.status)">
<text class="status-badge-text">{{ statusLabel(m.status) }}</text>
</view> </view>
<text class="mc-status" :class="inactiveStatusClass(m.status)">{{ statusLabel(m.status) }}</text>
</view> </view>
<view class="card-body"> <view class="mc-history-bottom">
<view v-if="m.remainingTimes !== null" class="info-row"> <view class="mc-history-info">
<text class="info-label">剩余课时</text> <text v-if="m.remainingTimes !== null" class="mc-date-label">剩余 {{ m.remainingTimes }} </text>
<text class="info-value">{{ m.remainingTimes }} </text> <text class="mc-date-label">{{ m.expireDate.slice(0, 10) }} 到期</text>
</view>
<view class="info-row">
<text class="info-label">有效期至</text>
<text class="info-value">{{ m.expireDate.slice(0, 10) }}</text>
</view> </view>
<button v-if="canRenewMembership(m)" class="mc-renew mc-renew--inactive" @tap="goRenew(m)">续同款</button>
</view> </view>
</view> </view>
</view> </view>
</view> </view>
<view class="scroll-bottom-spacer" /> <view class="scroll-bottom-spacer" />
</scroll-view> </scroll-view>
<view v-if="userStore.loggedIn && allMemberships.length" class="purchase-dock">
<!-- Buy more FAB --> <button class="purchase-btn" @tap="goStore">选购会员卡</button>
<view class="fab" @tap="goStore">
<text class="fab-icon">+</text>
<text class="fab-text">购买会员卡</text>
</view> </view>
</view> </view>
</template> </template>
<script setup lang="ts"> <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 type { MembershipWithCardType } from '@mp-pilates/shared'
import { MembershipStatus, CardTypeCategory } from '@mp-pilates/shared' import { MembershipStatus, CardTypeCategory } from '@mp-pilates/shared'
import { useUserStore } from '../../stores/user' import { useUserStore } from '../../stores/user'
import { getSystemLayout } from '../../utils/system' import { getSystemLayout } from '../../utils/system'
import { getCardTypeLabel, getMembershipProgressWidth, getMembershipUsedTimes } from '../../utils/format' import { getCardTypeLabel } from '../../utils/format'
import CustomNavBar from '../../components/CustomNavBar.vue' import CustomNavBar from '../../components/CustomNavBar.vue'
import OwnedMembershipCard from '../../components/OwnedMembershipCard.vue'
const userStore = useUserStore() const userStore = useUserStore()
// ─── Nav bar height ────────────────────────────────────── const navBarHeight = `${getSystemLayout().navBarHeight}px`
const navBarHeight = ref('64px') const pageHeight = ref(`${uni.getWindowInfo().windowHeight}px`)
// ─── State ──────────────────────────────────────────────── onResize(() => { pageHeight.value = `${uni.getWindowInfo().windowHeight}px` })
const loading = ref(false) const loading = computed(() => userStore.membershipsLoading)
const membershipNow = ref(Date.now())
const refreshing = ref(false) const refreshing = ref(false)
// ─── Computed from store ───────────────────────────────────
const allMemberships = computed(() => userStore.memberships as MembershipWithCardType[]) const allMemberships = computed(() => userStore.memberships as MembershipWithCardType[])
const activeMemberships = computed(() => const activeMemberships = computed(() =>
@@ -170,8 +104,6 @@ const inactiveMemberships = computed(() =>
allMemberships.value.filter((m) => m.status !== MembershipStatus.ACTIVE), allMemberships.value.filter((m) => m.status !== MembershipStatus.ACTIVE),
) )
// ─── Helpers ──────────────────────────────────────────────
function statusLabel(status: MembershipStatus): string { function statusLabel(status: MembershipStatus): string {
const map: Record<MembershipStatus, string> = { const map: Record<MembershipStatus, string> = {
[MembershipStatus.ACTIVE]: '有效', [MembershipStatus.ACTIVE]: '有效',
@@ -181,363 +113,92 @@ function statusLabel(status: MembershipStatus): string {
return map[status] ?? status return map[status] ?? status
} }
function statusBadgeClass(status: MembershipStatus): string { function inactiveStatusClass(status: MembershipStatus): string {
if (status === MembershipStatus.EXPIRED) return 'status-badge--expired' if (status === MembershipStatus.USED_UP) return 'mc-status--used'
if (status === MembershipStatus.USED_UP) return 'status-badge--used' return 'mc-status--expired'
return 'status-badge--expired'
} }
function stripClass(type: CardTypeCategory): string { const finiteBalance = computed(() => activeMemberships.value.reduce((sum, m) => sum + Math.max(0, m.remainingTimes ?? 0), 0))
if (type === CardTypeCategory.TRIAL) return 'card-strip--trial' const unlimitedCount = computed(() => activeMemberships.value.filter(m => m.remainingTimes === null).length)
if (type === CardTypeCategory.DURATION) return 'card-strip--duration' const remainingLabel = computed(() => !userStore.membershipsLoaded || userStore.membershipsError ? '—' : finiteBalance.value > 0 ? finiteBalance.value : unlimitedCount.value ? '不限次' : 0)
return 'card-strip--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'
}
// ─── Data loading ─────────────────────────────────────────
async function loadMemberships() { async function loadMemberships() {
loading.value = true membershipNow.value = Date.now()
try { if (!userStore.loggedIn) return
await userStore.fetchMemberships() await Promise.all([userStore.fetchMemberships(), userStore.fetchStats()])
} catch {
uni.showToast({ title: '加载失败,请下拉刷新', icon: 'none' })
} finally {
loading.value = false
}
} }
async function onRefresh() { async function onRefresh() {
if (refreshing.value) return
refreshing.value = true refreshing.value = true
await userStore.fetchMemberships() try { await loadMemberships() }
refreshing.value = false finally { refreshing.value = false }
}
function goProfile() { uni.switchTab({ url: '/pages/profile/index' }) }
function goBooking() {
uni.switchTab({ url: '/pages/booking/index' })
} }
function goStore() { function goStore() {
uni.$emit('scrollToCardShop')
uni.switchTab({ url: '/pages/home/index' }) uni.switchTab({ url: '/pages/home/index' })
} }
// ─── Lifecycle ──────────────────────────────────────────── function canRenewMembership(m: MembershipWithCardType): boolean {
onMounted(() => { return m.cardType.isActive && m.cardType.type !== CardTypeCategory.TRIAL
navBarHeight.value = `${getSystemLayout().navBarHeight}px` }
loadMemberships()
}) function goRenew(m: MembershipWithCardType) {
uni.navigateTo({ url: `/pages/card/detail?id=${m.cardTypeId}` })
}
onShow(loadMemberships)
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.membership-page { .membership-page { box-sizing: border-box; height: 100vh; display: flex; flex-direction: column; background: #fbf9f6; color: #514943; }
min-height: 100vh; .scroll { flex: 1; height: 0; min-height: 0; }
background: #f5f3f0; .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; }
.scroll { .empty-card { padding: 48rpx 32rpx; border-radius: 28rpx; background: #f2e9e3; display: flex; flex-direction: column; align-items: flex-start; gap: 16rpx; }
height: 100vh; .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; } }
/* ── Loading ─────────────────────────────────────────── */ .list { padding: 28rpx 32rpx 0; }
.loading-wrap { .group-section + .group-section { margin-top: 36rpx; }
padding: 24rpx; .group-header { display: flex; align-items: baseline; justify-content: space-between; gap: 20rpx; margin-bottom: 20rpx; }
display: flex; .group-title { font-size: 28rpx; font-weight: 500; }
flex-direction: column; .group-count { font-size: 22rpx; color: #8b817b; }
gap: 20rpx; .mc { --balance-bg: #f3ebe3; --balance-ink: #8b6c5b; padding: 28rpx; margin-bottom: 20rpx; border: 1rpx solid #eee8e3; border-radius: 28rpx; background: #fff; }
} .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; }
.skeleton-card { .mc-name { font-size: 30rpx; font-weight: 500; line-height: 1.5; overflow-wrap: anywhere; }
height: 220rpx; .mc-type-text { font-size: 22rpx; color: #8b817b; }
border-radius: 20rpx; .mc-status { flex-shrink: 0; padding: 6rpx 16rpx; border-radius: 999rpx; background: #ede9e4; color: #8b817b; font-size: 21rpx; line-height: 1.4; }
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%); .mc-date-label { font-size: 21rpx; color: #8b817b; line-height: 1.6; }
background-size: 400% 100%; .mc-actions { display: flex; gap: 16rpx; margin-top: 26rpx; }
animation: shimmer 1.4s infinite; .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; }
/* ── Empty ───────────────────────────────────────────── */ .mc--inactive { background: #f4f1ed; border-color: #eee8e3; .mc-name { font-size: 27rpx; font-weight: 400; color: #786d64; } }
.empty-wrap { .mc-history-bottom { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; margin-top: 22rpx; }
display: flex; .mc-history-info { display: flex; flex-direction: column; gap: 4rpx; }
flex-direction: column; .mc-renew--inactive { flex: none; flex-shrink: 0; height: 64rpx; line-height: 64rpx; background: #fffaf5; font-size: 23rpx; }
align-items: center; .purchase-dock { flex-shrink: 0; padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom)); border-top: 1rpx solid #eee8e3; background: #fbf9f6; }
justify-content: center; .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; } }
padding: 120rpx 40rpx; .scroll-bottom-spacer { height: 20rpx; }
gap: 20rpx; .practice-summary { padding: 32rpx; margin: 24rpx 32rpx 0; border-radius: 24rpx; background: #eef1e9; border: 1rpx solid #dce3d4; color: #465c4a; }
} .summary-heading { display: flex; flex-direction: column; gap: 12rpx; }
.summary-kicker { font-size: 18rpx; letter-spacing: 3rpx; color: #6d7d64; }
.empty-icon { .summary-title { font-size: 32rpx; font-family: 'Songti SC', 'STSong', serif; }
font-size: 80rpx; .summary-grid { display: flex; margin-top: 30rpx; padding-top: 24rpx; border-top: 1rpx solid #d4dec9; }
} .summary-grid > view { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 12rpx; text-align: center; }
.summary-grid > view + view { border-left: 1rpx solid #d4dec9; }
.empty-title { .summary-value { font-size: 42rpx; font-family: 'Baskerville', 'Times New Roman', serif; line-height: 1.3; font-variant-numeric: tabular-nums; }
font-size: 32rpx; .summary-label { font-size: 20rpx; color: #6d7d64; }
font-weight: 600; .summary-note, .usage-note { display: block; font-size: 21rpx; line-height: 1.8; color: #78816f; margin-top: 18rpx; }
color: #333; .summary-retry { padding: 16rpx 0 0; margin: 0; color: #6d7d64; text-align: left; background: transparent; font-size: 23rpx; &::after { border: 0; } }
} .owned-card-wrap { margin-bottom: 20rpx; }
.empty-sub {
font-size: 26rpx;
color: #999;
}
.empty-btn {
margin-top: 12rpx;
padding: 22rpx 60rpx;
border-radius: 44rpx;
background: $primary-dark;
box-shadow: 0 4rpx 16rpx rgba(201, 168, 124, 0.35);
}
.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, #5a7a8a, $primary-dark); }
&--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, #5a7a8a, $primary-dark); }
&--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: $primary-dark;
line-height: 1;
}
.highlight-unit {
font-size: 22rpx;
color: $primary-dark;
font-weight: 500;
}
.info-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.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, $primary-dark, $primary-color);
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: $primary-dark;
font-weight: 300;
line-height: 1;
}
.fab-text {
font-size: 28rpx;
font-weight: 700;
color: $primary-dark;
letter-spacing: 1rpx;
}
/* ── Spacer ──────────────────────────────────────────── */
.scroll-bottom-spacer {
height: 120rpx;
}
</style> </style>

View File

@@ -0,0 +1,8 @@
<template><view class="page" :style="{ paddingTop: navBarHeight + 'px' }"><CustomNavBar title="成长档案" show-back /><MemberProgress /></view></template>
<script setup lang="ts">
import CustomNavBar from '../../components/CustomNavBar.vue'
import MemberProgress from '../../components/MemberProgress.vue'
import { getSystemLayout } from '../../utils/system'
const navBarHeight = getSystemLayout().navBarHeight
</script>
<style scoped>.page{min-height:100vh;background:#fbf9f6;box-sizing:border-box;}</style>

View File

@@ -0,0 +1,256 @@
<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" hover-class="session--hover"
:aria-label="`${slot.startTime.slice(0, 5)} ${slot.endTime.slice(0, 5)}${slot.students.length} `" @tap="openSlot(slot.slotId)">
<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.stop="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 })
}
function openSlot(slotId: string) {
uni.navigateTo({ url: `/pages/booking/detail?slotId=${encodeURIComponent(slotId)}` })
}
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--hover { background: #f4f1ec; }
.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__status--completed { background: #e8e8e1; color: #5b6660; }
.student__status--cancelled { background: #efe5e0; color: #8c5d4f; text-decoration: line-through; }
.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>

View File

@@ -0,0 +1,143 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import type {
BodyPortraitAnswers,
BodyPortraitSessionResponse,
BodyPortraitSource,
BodyPortraitStats,
BodyPortraitTeaser,
BodyPortraitReport,
} from '@mp-pilates/shared'
import { EMPTY_ANSWERS } from './portrait-empty'
import { get, post, put } from '../utils/request'
const VISIT_KEY = 'portrait_visit_token'
const ACCESS_KEY = 'portrait_access_token'
const SOURCE_KEY = 'portrait_source'
const CAMPAIGN_KEY = 'portrait_campaign'
const REFERRAL_KEY = 'portrait_referral'
export const useBodyPortraitStore = defineStore('body-portrait', () => {
const visitToken = ref(String(uni.getStorageSync(VISIT_KEY) || ''))
const accessToken = ref(String(uni.getStorageSync(ACCESS_KEY) || ''))
const session = ref<BodyPortraitSessionResponse | null>(null)
const stats = ref<BodyPortraitStats | null>(null)
const loading = ref(false)
const answers = computed(() => session.value?.answers || EMPTY_ANSWERS)
const teaser = computed<BodyPortraitTeaser | null>(() => session.value?.teaser || null)
const report = computed<BodyPortraitReport | null>(() => session.value?.report || null)
const claimed = computed(() => !!session.value?.claimed && !!session.value?.report)
const assessmentId = computed(() => session.value?.assessmentId || '')
function persist() {
if (visitToken.value) uni.setStorageSync(VISIT_KEY, visitToken.value)
if (accessToken.value) uni.setStorageSync(ACCESS_KEY, accessToken.value)
}
function captureAttribution(query: Record<string, string | undefined> = {}) {
const source = query.source || query.utm_source
const campaign = query.campaign || query.campaign_id
const referral = query.inviteCode || query.ref || query.shareCode
if (source) uni.setStorageSync(SOURCE_KEY, source)
if (campaign) uni.setStorageSync(CAMPAIGN_KEY, campaign)
if (referral) uni.setStorageSync(REFERRAL_KEY, referral)
}
async function fetchStats() {
stats.value = await get<BodyPortraitStats>('/body-portrait/stats')
return stats.value
}
async function ensureSession() {
if (session.value?.accessToken) return session.value
loading.value = true
try {
if (!visitToken.value) {
const visit = await post<{ visitId: string; visitToken: string; source: BodyPortraitSource }>('/body-portrait/visits', {
source: String(uni.getStorageSync(SOURCE_KEY) || 'organic'),
campaignId: String(uni.getStorageSync(CAMPAIGN_KEY) || '') || undefined,
referralCode: String(uni.getStorageSync(REFERRAL_KEY) || '') || undefined,
landingPath: '/pages/portrait/index',
})
visitToken.value = visit.visitToken
}
const started = await post<BodyPortraitSessionResponse>('/body-portrait/assessments', { visitToken: visitToken.value })
accessToken.value = started.accessToken
session.value = started
persist()
return started
} finally {
loading.value = false
}
}
async function saveAnswers(next: Partial<BodyPortraitAnswers>) {
if (!accessToken.value) await ensureSession()
const updated = await put<BodyPortraitSessionResponse>('/body-portrait/assessments', {
accessToken: accessToken.value,
answers: { ...answers.value, ...next },
})
accessToken.value = updated.accessToken || accessToken.value
session.value = { ...updated, accessToken: accessToken.value }
persist()
return updated
}
async function complete() {
const updated = await post<BodyPortraitSessionResponse>('/body-portrait/assessments/complete', { accessToken: accessToken.value })
session.value = { ...updated, accessToken: accessToken.value }
return updated
}
async function claim() {
const updated = await post<BodyPortraitSessionResponse>('/body-portrait/assessments/claim', { accessToken: accessToken.value })
session.value = { ...updated, accessToken: accessToken.value, claimed: true }
return updated
}
async function loadMine() {
const latest = await get<BodyPortraitSessionResponse | null>('/body-portrait/me')
if (latest) session.value = { ...latest, accessToken: accessToken.value }
return latest
}
async function track(name: string) {
try {
const scopeKey = assessmentId.value || visitToken.value || `anon_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
await post('/body-portrait/events', {
name,
accessToken: accessToken.value || undefined,
idempotencyKey: `${name}:${scopeKey}`.slice(0, 80),
})
} catch {
// tracking must never block the user flow
}
}
function keepAnonymousOnLogout() {
session.value = session.value?.claimed ? null : session.value
}
return {
visitToken,
accessToken,
session,
stats,
loading,
answers,
teaser,
report,
claimed,
assessmentId,
captureAttribution,
fetchStats,
ensureSession,
saveAnswers,
complete,
claim,
loadMine,
track,
keepAnonymousOnLogout,
}
})

View File

@@ -6,8 +6,10 @@ import type {
BookingWithUser, BookingWithUser,
BookingStatusHistory, BookingStatusHistory,
CreateBookingDto, CreateBookingDto,
TeachingScheduleSlot,
} from '@mp-pilates/shared' } from '@mp-pilates/shared'
import { get, post, put } from '../utils/request' import { get, post, put } from '../utils/request'
import { useBodyPortraitStore } from './body-portrait'
/** Server paginated responses use `data` field, not `items` from the shared type */ /** Server paginated responses use `data` field, not `items` from the shared type */
interface ServerPaginatedResult<T> { interface ServerPaginatedResult<T> {
@@ -21,8 +23,10 @@ export const useBookingStore = defineStore('booking', () => {
const slots = ref<readonly TimeSlotWithBookingStatus[]>([]) const slots = ref<readonly TimeSlotWithBookingStatus[]>([])
const myBookings = ref<readonly BookingWithDetails[]>([]) const myBookings = ref<readonly BookingWithDetails[]>([])
const upcomingBookings = ref<readonly BookingWithDetails[]>([]) const upcomingBookings = ref<readonly BookingWithDetails[]>([])
const teachingSchedule = ref<readonly TeachingScheduleSlot[]>([])
const loadingSlots = ref(false) const loadingSlots = ref(false)
const loadingBookings = ref(false) const loadingBookings = ref(false)
const loadingTeachingSchedule = ref(false)
async function fetchSlots(date: string) { async function fetchSlots(date: string) {
loadingSlots.value = true loadingSlots.value = true
@@ -37,17 +41,36 @@ export const useBookingStore = defineStore('booking', () => {
} }
async function createBooking(dto: CreateBookingDto) { async function createBooking(dto: CreateBookingDto) {
const result = await post<BookingWithDetails>('/booking', dto as unknown as Record<string, unknown>) const portraitStore = useBodyPortraitStore()
const originAssessmentId = dto.originAssessmentId || (portraitStore.claimed ? portraitStore.assessmentId : undefined)
const result = await post<BookingWithDetails>('/booking', {
...dto,
...(originAssessmentId ? { originAssessmentId } : {}),
} as unknown as Record<string, unknown>)
return result 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) { async function cancelBooking(bookingId: string) {
const result = await put<BookingWithDetails>(`/booking/${bookingId}/cancel`) const result = await put<BookingWithDetails>(`/booking/${bookingId}/cancel`)
replaceBooking(result)
return result return result
} }
async function fetchMyBookings(status?: string) { async function fetchMyBookings(status?: string, opts: { silent?: boolean } = {}) {
loadingBookings.value = true if (!opts.silent) loadingBookings.value = true
try { try {
const params: Record<string, unknown> = status ? { status } : {} const params: Record<string, unknown> = status ? { status } : {}
const paginated = await get<ServerPaginatedResult<BookingWithDetails>>('/booking/my', params) const paginated = await get<ServerPaginatedResult<BookingWithDetails>>('/booking/my', params)
@@ -56,7 +79,7 @@ export const useBookingStore = defineStore('booking', () => {
console.error('Fetch bookings failed:', err) console.error('Fetch bookings failed:', err)
myBookings.value = [] myBookings.value = []
} finally { } finally {
loadingBookings.value = false if (!opts.silent) loadingBookings.value = false
} }
} }
@@ -70,6 +93,21 @@ export const useBookingStore = defineStore('booking', () => {
} }
} }
async function fetchTeachingSchedule(date: string) {
loadingTeachingSchedule.value = true
try {
const result = await get<TeachingScheduleSlot[]>('/admin/teaching-schedule', { date })
teachingSchedule.value = Array.isArray(result) ? result : []
return teachingSchedule.value
} catch (err) {
console.error('Fetch teaching schedule failed:', err)
teachingSchedule.value = []
throw err
} finally {
loadingTeachingSchedule.value = false
}
}
// ─── Admin methods ────────────────────────────────────────────────────── // ─── Admin methods ──────────────────────────────────────────────────────
async function fetchAllAdminBookings( async function fetchAllAdminBookings(
@@ -88,6 +126,7 @@ export const useBookingStore = defineStore('booking', () => {
const result = await put<BookingWithDetails>(`/booking/${bookingId}/confirm`, { const result = await put<BookingWithDetails>(`/booking/${bookingId}/confirm`, {
remark, remark,
}) })
replaceBooking(result)
return result return result
} }
@@ -95,6 +134,7 @@ export const useBookingStore = defineStore('booking', () => {
const result = await put<BookingWithDetails>(`/booking/${bookingId}/complete`, { const result = await put<BookingWithDetails>(`/booking/${bookingId}/complete`, {
remark, remark,
}) })
replaceBooking(result)
return result return result
} }
@@ -102,6 +142,7 @@ export const useBookingStore = defineStore('booking', () => {
const result = await put<BookingWithDetails>(`/booking/${bookingId}/noshow`, { const result = await put<BookingWithDetails>(`/booking/${bookingId}/noshow`, {
remark, remark,
}) })
replaceBooking(result)
return result return result
} }
@@ -124,13 +165,16 @@ export const useBookingStore = defineStore('booking', () => {
slots, slots,
myBookings, myBookings,
upcomingBookings, upcomingBookings,
teachingSchedule,
loadingSlots, loadingSlots,
loadingBookings, loadingBookings,
loadingTeachingSchedule,
fetchSlots, fetchSlots,
createBooking, createBooking,
cancelBooking, cancelBooking,
fetchMyBookings, fetchMyBookings,
fetchUpcomingBookings, fetchUpcomingBookings,
fetchTeachingSchedule,
fetchAllAdminBookings, fetchAllAdminBookings,
confirmBooking, confirmBooking,
completeBooking, completeBooking,
@@ -138,5 +182,6 @@ export const useBookingStore = defineStore('booking', () => {
fetchBookingHistory, fetchBookingHistory,
fetchSlotById, fetchSlotById,
fetchBookingById, fetchBookingById,
replaceBooking,
} }
}) })

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

@@ -0,0 +1,13 @@
import type { BodyPortraitAnswers } from '@mp-pilates/shared'
export const EMPTY_ANSWERS: BodyPortraitAnswers = {
concerns: [],
goal: null,
sittingHours: null,
exerciseFreq: null,
workPosture: null,
endOfDayFatigue: [],
afterSitting: [],
standingNotice: [],
safety: [],
}

View File

@@ -1,3 +1,5 @@
import { useInviteStore } from './invite'
import { useBodyPortraitStore } from './body-portrait'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import type { import type {
@@ -5,16 +7,33 @@ import type {
UserStatsResponse, UserStatsResponse,
MembershipWithCardType, MembershipWithCardType,
} from '@mp-pilates/shared' } 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 { wxLogin, isLoggedIn, logout as authLogout } from '../utils/auth'
import { setUnauthorizedHandler } from '../utils/session'
import { get, put } from '../utils/request' import { get, put } from '../utils/request'
import { ROUTES } from '../utils/routes' import { ROUTES } from '../utils/routes'
import { cacheSubscriptionMessageTemplateConfig, resetSubscriptionMessageTemplateCache } from '../utils/wechat-subscription'
function syncSubscriptionTemplates(profile?: Pick<UserProfileResponse, 'subscriptionMessageTemplates'> | null) {
cacheSubscriptionMessageTemplateConfig(profile?.subscriptionMessageTemplates)
}
export const useUserStore = defineStore('user', () => { export const useUserStore = defineStore('user', () => {
// State // State
const user = ref<UserProfileResponse | null>(null) const user = ref<UserProfileResponse | null>(null)
const stats = ref<UserStatsResponse | null>(null) const stats = ref<UserStatsResponse | null>(null)
const memberships = ref<readonly MembershipWithCardType[]>([]) const memberships = ref<readonly MembershipWithCardType[]>([])
const membershipsLoading = ref(false)
const membershipsLoaded = ref(false)
const membershipsError = ref(false)
const statsLoading = ref(false)
const statsError = ref(false)
let membershipRequestId = 0
let statsRequestId = 0
const token = ref<string>(uni.getStorageSync('token') as string || '') const token = ref<string>(uni.getStorageSync('token') as string || '')
// Getters // Getters
@@ -27,6 +46,8 @@ export const useUserStore = defineStore('user', () => {
memberships.value.filter((m) => m.status === MembershipStatus.ACTIVE), memberships.value.filter((m) => m.status === MembershipStatus.ACTIVE),
) )
const hasValidMembership = computed(() => activeMemberships.value.length > 0) const hasValidMembership = computed(() => activeMemberships.value.length > 0)
const renewalHint = computed(() => getMembershipRenewalHint(memberships.value))
const inviteShareEligible = computed(() => !!user.value?.inviteShareEligible)
// Actions // Actions
async function login() { async function login() {
@@ -34,6 +55,8 @@ export const useUserStore = defineStore('user', () => {
const result = await wxLogin() const result = await wxLogin()
token.value = result.token token.value = result.token
user.value = result.user user.value = result.user
await useInviteStore().refresh().catch(() => {})
syncSubscriptionTemplates(result.user)
return { user: result.user, isNewUser: result.isNewUser } return { user: result.user, isNewUser: result.isNewUser }
} catch (err) { } catch (err) {
console.error('Login failed:', err) console.error('Login failed:', err)
@@ -59,35 +82,65 @@ export const useUserStore = defineStore('user', () => {
if (!isLoggedIn()) return if (!isLoggedIn()) return
try { try {
user.value = await get<UserProfileResponse>('/user/profile') user.value = await get<UserProfileResponse>('/user/profile')
await useInviteStore().refresh().catch(() => {})
syncSubscriptionTemplates(user.value)
return user.value
} catch (err) { } catch (err) {
console.error('Fetch profile failed:', err) console.error('Fetch profile failed:', err)
} }
} }
async function fetchStats() { async function fetchStats(): Promise<boolean> {
if (!isLoggedIn()) return if (!isLoggedIn()) return false
const id = ++statsRequestId
const session = token.value
statsLoading.value = true
statsError.value = false
try { try {
stats.value = await get<UserStatsResponse>('/user/stats') const result = await get<UserStatsResponse>('/user/stats')
} catch (err) { if (id !== statsRequestId || session !== token.value) return false
console.error('Fetch stats failed:', err) stats.value = result
return true
} catch {
if (id === statsRequestId && session === token.value) statsError.value = true
return false
} finally {
if (id === statsRequestId && session === token.value) statsLoading.value = false
} }
} }
async function fetchMemberships() { async function fetchMemberships(): Promise<boolean> {
if (!isLoggedIn()) return if (!isLoggedIn()) return false
const id = ++membershipRequestId
const session = token.value
membershipsLoading.value = true
membershipsError.value = false
try { try {
memberships.value = await get<MembershipWithCardType[]>('/membership/my') const result = await get<MembershipWithCardType[]>('/membership/my')
} catch (err) { if (id !== membershipRequestId || session !== token.value) return false
console.error('Fetch memberships failed:', err) memberships.value = [...result]
membershipsLoaded.value = true
return true
} catch {
if (id === membershipRequestId && session === token.value) membershipsError.value = true
return false
} finally {
if (id === membershipRequestId && session === token.value) membershipsLoading.value = false
} }
} }
async function updateProfile(data: { nickname?: string; avatarUrl?: string }) { async function updateProfile(data: { nickname?: string; avatarUrl?: string }) {
const updated = await put<UserProfileResponse>('/user/profile', data) const updated = await put<UserProfileResponse>('/user/profile', data)
user.value = updated user.value = updated
syncSubscriptionTemplates(updated)
return updated return updated
} }
function setProfile(profile: UserProfileResponse) {
user.value = profile
syncSubscriptionTemplates(profile)
}
function checkAuth() { function checkAuth() {
if (isLoggedIn()) { if (isLoggedIn()) {
fetchProfile() fetchProfile()
@@ -95,15 +148,36 @@ export const useUserStore = defineStore('user', () => {
} }
} }
function logout() { function clearSession() {
authLogout() membershipRequestId++
statsRequestId++
membershipsLoading.value = false
membershipsLoaded.value = false
membershipsError.value = false
statsLoading.value = false
statsError.value = false
token.value = '' token.value = ''
useInviteStore().reset()
user.value = null user.value = null
stats.value = null stats.value = null
memberships.value = [] memberships.value = []
resetSubscriptionMessageTemplateCache()
useBodyPortraitStore().keepAnonymousOnLogout()
} }
function logout() {
authLogout()
clearSession()
}
setUnauthorizedHandler(clearSession)
return { return {
membershipsLoading,
membershipsLoaded,
membershipsError,
statsLoading,
statsError,
user, user,
stats, stats,
memberships, memberships,
@@ -113,12 +187,15 @@ export const useUserStore = defineStore('user', () => {
isAdmin, isAdmin,
activeMemberships, activeMemberships,
hasValidMembership, hasValidMembership,
renewalHint,
inviteShareEligible,
login, login,
loginWithSetup, loginWithSetup,
fetchProfile, fetchProfile,
fetchStats, fetchStats,
fetchMemberships, fetchMemberships,
updateProfile, updateProfile,
setProfile,
checkAuth, checkAuth,
logout, logout,
} }

View File

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

View File

@@ -3,6 +3,7 @@ import { CardTypeCategory } from '@mp-pilates/shared'
/** Minimal membership shape needed by progress/usage helpers. */ /** Minimal membership shape needed by progress/usage helpers. */
interface MembershipLike { interface MembershipLike {
readonly remainingTimes: number | null readonly remainingTimes: number | null
readonly totalTimes?: number | null
readonly cardType: { readonly totalTimes: number | null } readonly cardType: { readonly totalTimes: number | null }
} }
@@ -11,6 +12,13 @@ export function formatPrice(cents: number): string {
return (cents / 100).toFixed(2) return (cents / 100).toFixed(2)
} }
/** 中国自然日 YYYY-MM-DD用于时间戳与 UTC 日期列展示 */
export function formatChinaDate(value: Date | string): string {
const time = new Date(value).getTime()
if (!Number.isFinite(time)) return ''
return new Date(time + 8 * 3600_000).toISOString().slice(0, 10)
}
/** 格式化日期为 YYYY-MM-DD */ /** 格式化日期为 YYYY-MM-DD */
export function formatDate(date: Date | string): string { export function formatDate(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date const d = typeof date === 'string' ? new Date(date) : date
@@ -83,15 +91,82 @@ export function getCardGradientClass(type: CardTypeCategory | string): string {
return 'gradient--times' 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 { export function getMembershipProgressWidth(membership: MembershipLike): string {
if (membership.remainingTimes === null || !membership.cardType.totalTimes) return '0%' const totalTimes = getMembershipTotalTimes(membership)
const pct = (membership.remainingTimes / membership.cardType.totalTimes) * 100 if (membership.remainingTimes === null || !totalTimes) return '0%'
const pct = (membership.remainingTimes / totalTimes) * 100
return `${Math.max(0, Math.min(100, pct))}%` return `${Math.max(0, Math.min(100, pct))}%`
} }
/** 已使用次数 */ /** 已使用次数(不低于 0防止管理员调高剩余次数导致负值 */
export function getMembershipUsedTimes(membership: MembershipLike): number { export function getMembershipUsedTimes(membership: MembershipLike): number {
if (membership.remainingTimes === null || !membership.cardType.totalTimes) return 0 const totalTimes = getMembershipTotalTimes(membership)
return membership.cardType.totalTimes - membership.remainingTimes 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 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 type { ApiResponse, PaginatedData } from '@mp-pilates/shared'
import { notifyUnauthorized } from './session'
const BASE_URL = (() => { // 统一使用线上服务地址
try { const BASE_URL = 'https://focus.richarjiang.com/api'
const { miniProgram } = uni.getAccountInfoSync()
if (miniProgram.envVersion !== 'develop') {
return 'https://focus.richarjiang.com/api'
}
} catch {
// 非小程序环境,使用开发地址
}
return 'http://localhost:3000/api'
})()
interface RequestOptions { interface RequestOptions {
readonly url: string readonly url: string
@@ -19,6 +11,13 @@ interface RequestOptions {
readonly header?: Record<string, string> 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> { export function request<T>(options: RequestOptions): Promise<T> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const token = uni.getStorageSync('token') as string const token = uni.getStorageSync('token') as string
@@ -34,14 +33,15 @@ export function request<T>(options: RequestOptions): Promise<T> {
}, },
success: (res) => { success: (res) => {
if (res.statusCode === 401) { if (res.statusCode === 401) {
uni.removeStorageSync('token') if (!options.url.startsWith('/auth/login')) {
uni.showToast({ title: '请重新登录', icon: 'none' }) notifyUnauthorized()
}
reject(new Error('Unauthorized')) reject(new Error('Unauthorized'))
return return
} }
if (res.statusCode >= 400) { if (res.statusCode >= 400) {
const body = res.data as ApiResponse<unknown> const body = res.data as ApiResponse<unknown>
reject(new Error(body?.message || `请求失败 (${res.statusCode})`)) reject(new HttpRequestError(body?.message || `请求失败 (${res.statusCode})`, res.statusCode))
return return
} }
const body = res.data as ApiResponse<T> const body = res.data as ApiResponse<T>

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,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,354 @@
import {
SubscriptionMessageScene,
} from '@mp-pilates/shared'
import type {
ReportSubscriptionMessageRequestDto,
SubscriptionMessageRequestItem,
SubscriptionMessageTemplate,
SubscriptionMessageTemplateConfig,
SubscriptionQuotaItem,
SubscriptionQuotasResponse,
UserProfileResponse,
} from '@mp-pilates/shared'
import { get, 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
}
function getTemplateConfigSync(): SubscriptionMessageTemplateConfig | null {
if (cachedConfig) {
return cachedConfig
}
const stored = uni.getStorageSync(TEMPLATE_CONFIG_STORAGE_KEY) as SubscriptionMessageTemplateConfig | ''
if (!stored || !Array.isArray(stored.templates)) {
return null
}
cachedConfig = {
templates: stored.templates.filter((item) => item.templateId),
}
return cachedConfig
}
async function fetchTemplateConfig(): Promise<SubscriptionMessageTemplateConfig> {
const config = getTemplateConfigSync()
if (!config) {
throw new Error('订阅消息模板尚未初始化,请重新进入页面后重试')
}
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>)
}
function normalizeSubscribeResults(
templates: SubscriptionMessageTemplate[],
result: RequestSubscribeMessageSuccess,
): SubscriptionMessageRequestItem[] {
return 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)
}
export const SUBSCRIBE_BUNDLE_BOOKING: SubscriptionMessageScene[] = [
SubscriptionMessageScene.BOOKING_CREATED,
SubscriptionMessageScene.CLASS_REMINDER,
SubscriptionMessageScene.BOOKING_CANCELLED,
]
export const SUBSCRIBE_BUNDLE_CANCEL: SubscriptionMessageScene[] = [
SubscriptionMessageScene.BOOKING_CANCELLED,
SubscriptionMessageScene.CLASS_REMINDER,
]
/**
* 在当前调用栈同步调起 `uni.requestSubscribeMessage`(支持多场景组合打包,最多 3 个模板)。
* 微信要求授权框必须落在 tap / 支付 success 的同步栈里,因此这里不能先 `await`。
*/
export function requestSubscriptionBundle(scenes: SubscriptionMessageScene[]): Promise<SubscriptionMessageRequestItem[]> {
if (!isMpWeixin()) {
return Promise.resolve([])
}
const config = getTemplateConfigSync()
if (!config) {
return Promise.reject(new Error('订阅消息模板尚未初始化,请重新进入页面后重试'))
}
// 按场景收集模板,去重 templateId微信单次最多支持 3 个模板
const templateMap = new Map<string, SubscriptionMessageTemplate>()
for (const scene of scenes) {
const list = getTemplatesByScene(config, scene)
for (const tpl of list) {
if (tpl.templateId && !templateMap.has(tpl.templateId)) {
templateMap.set(tpl.templateId, tpl)
}
if (templateMap.size >= 3) break
}
if (templateMap.size >= 3) break
}
const templates = Array.from(templateMap.values())
if (templates.length === 0) {
console.error('[subscribe] no templates matched bundle', stringifyDebugPayload({ scenes, config, debugContext: getSubscribeDebugContext() }))
return Promise.resolve([])
}
const templateIds = templates.map((item) => item.templateId)
const debugContext = getSubscribeDebugContext()
console.log('[subscribe] requestSubscriptionBundle:start', stringifyDebugPayload({ scenes, templateIds, templates, debugContext }))
return new Promise<SubscriptionMessageRequestItem[]>((resolve, reject) => {
uni.requestSubscribeMessage({
tmplIds: templateIds,
success: (res) => {
const response = res as RequestSubscribeMessageSuccess
const requests = normalizeSubscribeResults(templates, response)
console.log('[subscribe] requestSubscriptionBundle:success', stringifyDebugPayload({ scenes, response, templateIds, debugContext }))
console.log('[subscribe] requestSubscriptionBundle:normalized', stringifyDebugPayload({ scenes, result: response, requests, templateIds, debugContext }))
void reportResults(requests)
.then(() => resolve(requests))
.catch(reject)
},
fail: (err) => {
console.error('[subscribe] requestSubscriptionBundle:fail', stringifyDebugPayload({ scenes, error: err, templateIds, debugContext }))
reject(buildSubscribeError(err as RequestSubscribeMessageFail, scenes[0], templateIds))
},
})
})
}
export function requestSubscriptionMessage(scene: SubscriptionMessageScene): Promise<SubscriptionMessageRequestItem[]> {
return requestSubscriptionBundle([scene])
}
/**
* 约课三合一组合授权包含约课成功确认、开课前1小时提醒、课程取消通知
* 一次点击,三个场景额度同时 +1
*/
export function requestBookingBundleSubscriptionMessage(): Promise<SubscriptionMessageRequestItem[]> {
return requestSubscriptionBundle(SUBSCRIBE_BUNDLE_BOOKING)
}
/**
* 兼容原有命名,直接升级为约课三合一组合授权
*/
export function requestBookingCreatedSubscriptionMessage(): Promise<SubscriptionMessageRequestItem[]> {
return requestBookingBundleSubscriptionMessage()
}
/**
* 取消预约时的组合授权:课程取消通知 + 下次上课提醒
*/
export function requestBookingCancelSubscriptionMessage(): Promise<SubscriptionMessageRequestItem[]> {
return requestSubscriptionBundle(SUBSCRIBE_BUNDLE_CANCEL)
}
/**
* 上课前1小时提醒单项授权
*/
export function requestClassReminderSubscriptionMessage(): Promise<SubscriptionMessageRequestItem[]> {
return requestSubscriptionMessage(SubscriptionMessageScene.CLASS_REMINDER)
}
/**
* 获取当前登录用户的各订阅场景额度水位
*/
export async function fetchUserSubscriptionQuotas(): Promise<SubscriptionQuotaItem[]> {
try {
const res = await get<SubscriptionQuotasResponse>('/user/subscription-messages/quotas')
return res.quotas || []
} catch (error) {
console.warn('[subscribe] fetchUserSubscriptionQuotas failed', error)
return []
}
}
/**
* 引导打开微信系统设置页,供用户恢复通知授权
*/
export function openSubscribeSettings(): Promise<boolean> {
return new Promise<boolean>((resolve) => {
uni.openSetting({
success: (res) => {
resolve(!!res.authSetting)
},
fail: () => resolve(false),
})
})
}
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

@@ -19,3 +19,20 @@ API_BASE_URL=https://focus.richarjiang.com/
# Server # Server
PORT=3000 PORT=3000
WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED=antYfc85gvwImFZ9kM4UiqMOywJxbqFVgKHLH3NikII
WX_SUBSCRIBE_TEMPLATE_BOOKING_CANCELLED=5YX4ml6XwC0NegITxqsUXBPJzwKeOiM4VRZCaTiCgFM
WX_SUBSCRIBE_TEMPLATE_CLASS_REMINDER=CHwcRtAu-Bo6kYt-cmHMhBI4gDZdfoTarYqZotWQEi0
WX_SUBSCRIBE_TEMPLATE_CLASS_REVIEW=QJaTOSq_QpyL_spdNRTUfbkmeWDDi5iDAYZyXrFAPc8
# 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,19 @@
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
# WeChat subscribe message for class review reminders (24h after completion)
WX_SUBSCRIBE_TEMPLATE_CLASS_REVIEW=
WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED=
WX_SUBSCRIBE_TEMPLATE_BOOKING_CANCELLED=5YX4ml6XwC0NegITxqsUXBPJzwKeOiM4VRZCaTiCgFM
WX_SUBSCRIBE_TEMPLATE_CLASS_REMINDER=CHwcRtAu-Bo6kYt-cmHMhBI4gDZdfoTarYqZotWQEi0

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