Compare commits
22 Commits
d45a5b2c14
...
v0.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7e339e40c | ||
|
|
8e2db3a74a | ||
|
|
f54a12efbd | ||
|
|
b937895bc9 | ||
|
|
b4b3caac70 | ||
|
|
d1f193e13e | ||
|
|
301f9ae385 | ||
|
|
87d946adb5 | ||
|
|
86ad9ee64f | ||
|
|
f5c7b7eaac | ||
|
|
88cd8419c8 | ||
|
|
801e2f47da | ||
|
|
15d96c722b | ||
|
|
14d7c03b05 | ||
|
|
bd3d519b4f | ||
|
|
9575210b06 | ||
|
|
b02f38dcc7 | ||
|
|
4dacd908a6 | ||
|
|
6ab16f508a | ||
|
|
7ce7cef77c | ||
|
|
52cc3a2985 | ||
|
|
497837c1d8 |
252
.claude/skills/wechat-devtools-http-preview/SKILL.md
Normal file
252
.claude/skills/wechat-devtools-http-preview/SKILL.md
Normal file
@@ -0,0 +1,252 @@
|
||||
---
|
||||
name: wechat-devtools-http-preview
|
||||
description: '通过微信开发者工具 HTTP V2 接口完成小程序登录检查、自动预览、上传体验版等操作。适用于用户提到微信开发者工具 HTTP、自动预览、体验版上传、命令行发布体验版、流水线触发开发者工具发布时。'
|
||||
license: MIT
|
||||
allowed-tools: Bash
|
||||
---
|
||||
|
||||
# 微信开发者工具 HTTP V2 体验版发布
|
||||
|
||||
## 适用场景
|
||||
|
||||
当用户出现以下意图时使用本 skill:
|
||||
|
||||
- 希望通过命令行或脚本调用微信开发者工具。
|
||||
- 希望上传小程序体验版。
|
||||
- 希望生成或刷新预览二维码。
|
||||
- 希望把开发者工具 HTTP 能力接入本地自动化流程。
|
||||
- 提到 `HTTP V2`、`/v2/upload`、`/v2/preview`、`/v2/autopreview`、`微信开发者工具端口` 等关键词。
|
||||
|
||||
## 核心结论
|
||||
|
||||
微信开发者工具在启动后会自动开启本地 HTTP 服务。对于“通过命令行发布体验版”这个目标,最直接的路径是:
|
||||
|
||||
1. 确认开发者工具已启动并拿到本地端口。
|
||||
2. 确认工具已登录。
|
||||
3. 如有需要先执行 `/v2/open` 打开项目。
|
||||
4. 如项目依赖 npm,必要时先执行 `/v2/buildnpm`。
|
||||
5. 调用 `/v2/upload` 上传体验版代码。
|
||||
6. 如需同时给测试同学扫码,调用 `/v2/preview` 或 `/v2/autopreview`。
|
||||
|
||||
文档同时明确说明:如果场景是完全不依赖开发者工具的 CI/CD,官方更推荐 `miniprogram-ci`。因此本 skill 的定位是“基于本机已安装且已运行的微信开发者工具做自动化”,不是替代纯 CI 方案。
|
||||
|
||||
## 文档沉淀
|
||||
|
||||
根据微信开发者工具 HTTP 文档,需要记住这些约束:
|
||||
|
||||
- 接口路径统一使用 `/v2` 前缀。
|
||||
- HTTP 服务会在开发者工具启动后自动开启。
|
||||
- 端口号记录在用户目录下的 `.ide` 文件。
|
||||
- `project` 参数一般都要求传项目绝对路径,且必须 URL encode。
|
||||
- 项目目录必须存在合法的 `project.config.json`,并至少包含 `appid` 与 `projectname`。
|
||||
- `upload` 用于上传代码,也就是生成体验版。
|
||||
- `preview` 返回预览二维码。
|
||||
- `autopreview` 会自动刷新并预览项目,适合频繁本地联调。
|
||||
- `islogin` 可用于判断当前开发者工具是否已登录。
|
||||
- `login` 可输出二维码,支持 `image`、`base64`、`terminal` 三种格式。
|
||||
- `info-output` 可把预览或上传附加信息输出到 JSON 文件,适合自动化流程记录产物。
|
||||
|
||||
## 端口定位
|
||||
|
||||
开发者工具端口号文件:
|
||||
|
||||
- macOS: `~/Library/Application Support/微信开发者工具/<MD5>/Default/.ide`
|
||||
- Windows: `~/AppData/Local/微信开发者工具/User Data/<MD5>/Default/.ide`
|
||||
|
||||
文档给出的 MD5 规则:`MD5(${installPath}${nwVersion})`
|
||||
|
||||
已知默认值:
|
||||
|
||||
- macOS: `installPath = /Applications/wechatwebdevtools.app/Contents/MacOS`
|
||||
- macOS: `nwVersion = ''`
|
||||
- Windows: `installPath = 微信开发者工具.exe 所在目录`
|
||||
- Windows: `nwVersion = installPath/version` 文件中 `latestNw` 的值
|
||||
|
||||
## 标准工作流
|
||||
|
||||
### 1. 检查工具是否启动
|
||||
|
||||
先读取 `.ide` 文件中的端口号;如果没有端口文件,说明工具大概率未启动,先提示用户启动微信开发者工具。
|
||||
|
||||
### 2. 检查是否已登录
|
||||
|
||||
调用:
|
||||
|
||||
```bash
|
||||
curl "http://127.0.0.1:${PORT}/v2/islogin"
|
||||
```
|
||||
|
||||
如果未登录,调用 `/v2/login`,按需要输出二维码:
|
||||
|
||||
```bash
|
||||
curl "http://127.0.0.1:${PORT}/v2/login?qr-format=terminal"
|
||||
curl "http://127.0.0.1:${PORT}/v2/login?qr-format=base64&qr-output=%2Ftmp%2Fwechat-login.txt"
|
||||
curl "http://127.0.0.1:${PORT}/v2/login?result-output=%2Ftmp%2Fwechat-login-result.json"
|
||||
```
|
||||
|
||||
### 3. 打开或刷新项目
|
||||
|
||||
```bash
|
||||
curl "http://127.0.0.1:${PORT}/v2/open?project=${ENCODED_PROJECT}"
|
||||
```
|
||||
|
||||
当用户只要求上传或预览时,这一步不是必选,但执行后通常更稳定。
|
||||
|
||||
### 4. 构建 npm
|
||||
|
||||
当项目启用了小程序 npm 并且近期依赖有变更时执行:
|
||||
|
||||
```bash
|
||||
curl "http://127.0.0.1:${PORT}/v2/buildnpm?project=${ENCODED_PROJECT}&compile-type=miniprogram"
|
||||
```
|
||||
|
||||
### 5. 上传体验版
|
||||
|
||||
体验版发布的核心接口就是:
|
||||
|
||||
```bash
|
||||
curl "http://127.0.0.1:${PORT}/v2/upload?project=${ENCODED_PROJECT}&version=${VERSION}&desc=${DESC}"
|
||||
```
|
||||
|
||||
推荐同时加 `info-output`:
|
||||
|
||||
```bash
|
||||
curl "http://127.0.0.1:${PORT}/v2/upload?project=${ENCODED_PROJECT}&version=${VERSION}&desc=${DESC}&info-output=${ENCODED_INFO_OUTPUT}"
|
||||
```
|
||||
|
||||
参数要求:
|
||||
|
||||
- `project`:必填,项目绝对路径。
|
||||
- `version`:必填,版本号。
|
||||
- `desc`:可选,版本备注。
|
||||
- `info-output`:可选,输出上传附加信息 JSON。
|
||||
|
||||
### 6. 生成预览二维码
|
||||
|
||||
如果用户还需要扫码体验,可继续调用:
|
||||
|
||||
```bash
|
||||
curl "http://127.0.0.1:${PORT}/v2/preview?project=${ENCODED_PROJECT}&qr-format=terminal"
|
||||
curl "http://127.0.0.1:${PORT}/v2/preview?project=${ENCODED_PROJECT}&qr-format=base64&qr-output=${ENCODED_QR_PATH}"
|
||||
curl "http://127.0.0.1:${PORT}/v2/autopreview?project=${ENCODED_PROJECT}&info-output=${ENCODED_INFO_OUTPUT}"
|
||||
```
|
||||
|
||||
区别:
|
||||
|
||||
- `/v2/preview`:单次预览,拿二维码最直接。
|
||||
- `/v2/autopreview`:更适合联调时自动刷新预览。
|
||||
|
||||
## 推荐命令模板
|
||||
|
||||
### macOS 读取端口
|
||||
|
||||
如果已知是默认安装路径,可用下面的方式快速算出 `.ide` 路径:
|
||||
|
||||
```bash
|
||||
MD5=$(printf '/Applications/wechatwebdevtools.app/Contents/MacOS' | md5)
|
||||
PORT_FILE="$HOME/Library/Application Support/微信开发者工具/${MD5}/Default/.ide"
|
||||
PORT=$(cat "$PORT_FILE")
|
||||
```
|
||||
|
||||
### URL encode 项目路径
|
||||
|
||||
```bash
|
||||
PROJECT="/absolute/path/to/miniprogram"
|
||||
ENCODED_PROJECT=$(python3 - <<'PY'
|
||||
import os, urllib.parse
|
||||
print(urllib.parse.quote(os.environ['PROJECT'], safe=''))
|
||||
PY
|
||||
)
|
||||
```
|
||||
|
||||
### 上传体验版完整示例
|
||||
|
||||
```bash
|
||||
PROJECT="/absolute/path/to/miniprogram"
|
||||
VERSION="1.2.3"
|
||||
DESC="体验版发布:修复预约课表"
|
||||
INFO_OUTPUT="/tmp/wechat-upload-info.json"
|
||||
|
||||
ENCODED_PROJECT=$(python3 - <<'PY'
|
||||
import os, urllib.parse
|
||||
print(urllib.parse.quote(os.environ['PROJECT'], safe=''))
|
||||
PY
|
||||
)
|
||||
|
||||
ENCODED_DESC=$(python3 - <<'PY'
|
||||
import os, urllib.parse
|
||||
print(urllib.parse.quote(os.environ['DESC'], safe=''))
|
||||
PY
|
||||
)
|
||||
|
||||
ENCODED_INFO_OUTPUT=$(python3 - <<'PY'
|
||||
import os, urllib.parse
|
||||
print(urllib.parse.quote(os.environ['INFO_OUTPUT'], safe=''))
|
||||
PY
|
||||
)
|
||||
|
||||
curl "http://127.0.0.1:${PORT}/v2/upload?project=${ENCODED_PROJECT}&version=${VERSION}&desc=${ENCODED_DESC}&info-output=${ENCODED_INFO_OUTPUT}"
|
||||
```
|
||||
|
||||
## 执行规则
|
||||
|
||||
当你代表用户执行这个流程时,按下面顺序做:
|
||||
|
||||
1. 先确认当前系统是否安装并启动了微信开发者工具。
|
||||
2. 优先读取 `.ide` 端口文件,而不是盲猜端口。
|
||||
3. 上传前先验证 `project.config.json` 是否存在且含 `appid`、`projectname`。
|
||||
4. 涉及路径、备注、输出文件参数时,一律 URL encode。
|
||||
5. 如果 `islogin` 未登录,先引导用户扫码登录,不要直接继续上传。
|
||||
6. 如果用户目标是“发布体验版”,优先使用 `/v2/upload`;不要误用 `/preview` 代替。
|
||||
7. 如果用户目标是“出二维码给别人扫”,优先使用 `/v2/preview`。
|
||||
8. 如果用户目标是“边改边自动刷新”,优先使用 `/v2/autopreview`。
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 端口文件不存在
|
||||
|
||||
说明通常是开发者工具没启动,或者安装路径/MD5 推导错了。
|
||||
|
||||
排查顺序:
|
||||
|
||||
1. 让用户先手动打开微信开发者工具。
|
||||
2. 检查是否使用了正确安装路径。
|
||||
3. 重新计算 MD5。
|
||||
|
||||
### 调用 HTTP 接口失败
|
||||
|
||||
优先检查:
|
||||
|
||||
1. 是否能访问 `http://127.0.0.1:${PORT}/v2/islogin`
|
||||
2. `PORT` 是否来自正确的 `.ide` 文件。
|
||||
3. 开发者工具版本是否支持 `HTTP V2`。
|
||||
|
||||
### 上传失败
|
||||
|
||||
优先检查:
|
||||
|
||||
1. `project` 是否为绝对路径。
|
||||
2. 是否已 URL encode。
|
||||
3. `project.config.json` 是否存在。
|
||||
4. `project.config.json` 里是否包含 `appid` 和 `projectname`。
|
||||
5. 是否已登录工具。
|
||||
6. npm 依赖是否需要先执行 `/v2/buildnpm`。
|
||||
|
||||
## 输出要求
|
||||
|
||||
当用户让你“帮我发布体验版”时,最终回复必须明确交代:
|
||||
|
||||
- 是否成功调用了 `/v2/upload`
|
||||
- 使用的版本号与备注
|
||||
- 是否生成了 `info-output` 文件
|
||||
- 如果还做了预览,二维码输出到哪里或以什么格式返回
|
||||
|
||||
如果没有成功执行,必须明确停在哪一步,不要模糊地说“可能发布了”。
|
||||
|
||||
## 引用来源
|
||||
|
||||
本 skill 基于微信开放文档:
|
||||
|
||||
- 页面:微信开发者工具 HTTP V2
|
||||
- 关键接口:`/v2/login`、`/v2/islogin`、`/v2/open`、`/v2/buildnpm`、`/v2/upload`、`/v2/preview`、`/v2/autopreview`
|
||||
|
||||
@@ -85,4 +85,9 @@ pnpm deploy:server # 部署后端到生产环境
|
||||
|
||||
### Admin Store (`src/stores/admin.ts`)
|
||||
- 聚合所有管理端 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`,不删除审计记录。
|
||||
|
||||
@@ -117,6 +117,7 @@ echo_info "文件上传完成"
|
||||
# 6. 服务器端安装依赖并重启
|
||||
echo_info "服务器部署操作..."
|
||||
ssh ${SERVER_USER}@${SERVER_HOST} "bash -l" << 'ENDSSH'
|
||||
set -e
|
||||
export PATH="$HOME/.nvm/versions/node/v22.17.1/bin:$PATH"
|
||||
SERVER_PATH="/usr/local/web/mp-pilates-server"
|
||||
PM2_APP_NAME="mp-pilates-server"
|
||||
|
||||
338
docs/STUDIO_COS_SETUP.md
Normal file
338
docs/STUDIO_COS_SETUP.md
Normal file
@@ -0,0 +1,338 @@
|
||||
# 工作室画廊 COS 接入配置说明
|
||||
|
||||
本文档对应当前仓库当前实现。
|
||||
|
||||
现在已经不再使用 STS `AssumeRole`。
|
||||
当前方案改为:
|
||||
|
||||
- 服务端使用长期密钥直接签发 COS POST Policy
|
||||
- 管理中心小程序拿到表单签名后直传 COS
|
||||
- 工作室配置中的 `logo`、`bannerUrl`、`photos` 保存最终可访问 URL
|
||||
|
||||
当前实现代码入口:
|
||||
|
||||
- `packages/server/src/studio/studio-upload.service.ts`
|
||||
- `packages/server/src/studio/studio.controller.ts`
|
||||
- `packages/app/src/utils/studio-upload.ts`
|
||||
- `packages/app/src/pages/admin/studio.vue`
|
||||
|
||||
## 一、整体链路
|
||||
|
||||
1. 管理中心点击上传图片。
|
||||
2. 小程序请求服务端 `POST /api/admin/studio/upload-credentials`。
|
||||
3. 服务端用 `COS_SECRET_ID`、`COS_SECRET_KEY` 直接生成一组 POST Policy 表单字段。
|
||||
4. 服务端把 `uploadUrl`、`key`、`formData`、`fileUrl`、`expiresAt` 返回给小程序。
|
||||
5. 小程序使用 `uni.uploadFile` 直接上传到 COS。
|
||||
6. 上传成功后,把 URL 保存到工作室配置,再调用 `PUT /api/admin/studio/info` 落库。
|
||||
|
||||
这个方案没有临时密钥,也没有角色扮演。
|
||||
安全边界来自两层:
|
||||
|
||||
- 服务端只为单个对象 key 签发一次表单策略
|
||||
- 表单策略有明确过期时间,过期后自动失效
|
||||
|
||||
## 二、这个方案的本质
|
||||
|
||||
你现在选的是“服务端代签名”的直传方案。
|
||||
它和 STS 的差别是:
|
||||
|
||||
- STS:给前端一段时间内可用的短期密钥
|
||||
- 当前方案:不给前端密钥,只给前端一个短时有效的上传表单签名
|
||||
|
||||
所以结论很直接:
|
||||
|
||||
- 仍然有有效期
|
||||
- 但有效期作用在 POST Policy 上,不是作用在临时密钥上
|
||||
|
||||
当前代码里默认有效期是 `1800` 秒。
|
||||
环境变量:
|
||||
|
||||
- `COS_UPLOAD_DURATION_SECONDS`
|
||||
|
||||
当前实现限制范围:
|
||||
|
||||
- 最短 `300` 秒
|
||||
- 最长 `7200` 秒
|
||||
|
||||
## 三、你现在真正需要准备的东西
|
||||
|
||||
先确认下面几个信息:
|
||||
|
||||
- COS Bucket 名称,例如 `plates-1251306435`
|
||||
- COS 所在地域,例如 `ap-guangzhou`
|
||||
- 服务端使用的 COS 长期密钥 `SecretId` / `SecretKey`
|
||||
- 图片上传前缀,例如 `mp/studio`
|
||||
- 图片访问域名
|
||||
|
||||
建议约定:
|
||||
|
||||
- Bucket:`plates-1251306435`
|
||||
- Region:`ap-guangzhou`
|
||||
- Prefix:`mp/studio`
|
||||
|
||||
## 四、COS 控制台配置
|
||||
|
||||
### 1. 创建或确认 Bucket
|
||||
|
||||
控制台路径:`对象存储 COS`
|
||||
|
||||
建议:
|
||||
|
||||
- 地域选 `广州` 或你当前实际地域
|
||||
- 存储类型标准存储即可
|
||||
- Bucket 名称和环境变量保持完全一致
|
||||
|
||||
### 2. 图片访问方式
|
||||
|
||||
当前实现保存的是直接图片 URL。
|
||||
所以图片必须能被小程序和前台直接访问。
|
||||
|
||||
你有两种方式:
|
||||
|
||||
1. 直接使用 COS 源站并允许读
|
||||
2. 配 CDN / 自定义域名并让这个域名可直接访问图片
|
||||
|
||||
如果你什么都不配,上传成功后图片可能打不开。
|
||||
|
||||
最直接做法:
|
||||
|
||||
- 让这个图片 Bucket 对外可读
|
||||
|
||||
更稳妥做法:
|
||||
|
||||
- 单独图片 Bucket
|
||||
- 用 CDN 域名做 `COS_PUBLIC_BASE_URL`
|
||||
|
||||
### 3. 微信小程序合法域名
|
||||
|
||||
微信公众平台需要补白名单:
|
||||
|
||||
- `request 合法域名`:你的后端 API 域名
|
||||
- `uploadFile 合法域名`:`https://<bucket>.cos.<region>.myqcloud.com`
|
||||
- `downloadFile 合法域名`:图片访问域名
|
||||
|
||||
如果图片访问也走 COS 源站,那么 `downloadFile 合法域名` 同样加:
|
||||
|
||||
- `https://<bucket>.cos.<region>.myqcloud.com`
|
||||
|
||||
例如:
|
||||
|
||||
- `https://focus.richarjiang.com`
|
||||
- `https://plates-1251306435.cos.ap-guangzhou.myqcloud.com`
|
||||
|
||||
## 五、服务端账号需要什么权限
|
||||
|
||||
现在已经不需要:
|
||||
|
||||
- STS
|
||||
- CAM 角色
|
||||
- `AssumeRole`
|
||||
- 角色信任策略
|
||||
- `COS_UPLOAD_ROLE_ARN`
|
||||
|
||||
现在服务端只需要一对可以给目标 Bucket 生成上传签名的长期密钥。
|
||||
|
||||
最简单的做法是:
|
||||
|
||||
- 用你的主账号密钥
|
||||
|
||||
但生产上更合理的是:
|
||||
|
||||
- 建一个专用 CAM 用户,只给这个 Bucket 上传相关权限
|
||||
|
||||
### 推荐 CAM 用户权限策略
|
||||
|
||||
如果你要建专用 CAM 用户,给它绑定下面这类策略即可。
|
||||
|
||||
把下面真实值替换成你的实际资源:
|
||||
|
||||
- 地域:`ap-guangzhou`
|
||||
- AppId:`1251306435`
|
||||
- Bucket:`plates-1251306435`
|
||||
- Prefix:`mp/studio`
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "2.0",
|
||||
"statement": [
|
||||
{
|
||||
"effect": "allow",
|
||||
"action": [
|
||||
"name/cos:PutObject",
|
||||
"name/cos:PostObject"
|
||||
],
|
||||
"resource": [
|
||||
"qcs::cos:ap-guangzhou:uid/1251306435:plates-1251306435/mp/studio/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
如果你后续还要服务端删除对象,再补:
|
||||
|
||||
- `name/cos:DeleteObject`
|
||||
|
||||
当前仓库实现不需要删除对象,所以先不要额外放大权限。
|
||||
|
||||
## 六、服务端环境变量
|
||||
|
||||
把下面变量配置到 `packages/server/.env` 或线上环境:
|
||||
|
||||
```env
|
||||
COS_SECRET_ID=your-cos-secret-id
|
||||
COS_SECRET_KEY=your-cos-secret-key
|
||||
COS_BUCKET=plates-1251306435
|
||||
COS_REGION=ap-guangzhou
|
||||
COS_PUBLIC_BASE_URL=https://plates-1251306435.cos.ap-guangzhou.myqcloud.com
|
||||
COS_UPLOAD_PREFIX=mp/studio
|
||||
COS_UPLOAD_DURATION_SECONDS=1800
|
||||
```
|
||||
|
||||
各字段含义:
|
||||
|
||||
- `COS_SECRET_ID`:用于签发 POST Policy 的长期密钥 ID
|
||||
- `COS_SECRET_KEY`:用于签发 POST Policy 的长期密钥 Key
|
||||
- `COS_BUCKET`:上传目标 Bucket
|
||||
- `COS_REGION`:Bucket 地域
|
||||
- `COS_PUBLIC_BASE_URL`:最终展示图片的访问域名
|
||||
- `COS_UPLOAD_PREFIX`:统一对象前缀
|
||||
- `COS_UPLOAD_DURATION_SECONDS`:Policy 有效期秒数
|
||||
|
||||
现在可以删除或忽略这些旧配置:
|
||||
|
||||
- `COS_UPLOAD_ROLE_ARN`
|
||||
- `COS_APP_ID`
|
||||
- `COS_UPLOAD_ROLE_SESSION_NAME`
|
||||
|
||||
它们对当前实现已经没用。
|
||||
|
||||
## 七、控制台操作清单
|
||||
|
||||
按这个顺序做:
|
||||
|
||||
1. 确认 COS Bucket 已存在。
|
||||
2. 确认图片访问域名对外可读。
|
||||
3. 在微信公众平台加好 `request` / `uploadFile` / `downloadFile` 合法域名。
|
||||
4. 准备一对 COS 长期密钥。
|
||||
5. 把 `COS_SECRET_ID`、`COS_SECRET_KEY`、`COS_BUCKET`、`COS_REGION`、`COS_PUBLIC_BASE_URL`、`COS_UPLOAD_PREFIX` 配到服务端。
|
||||
6. 重启服务端。
|
||||
7. 在管理中心上传一张图片测试。
|
||||
|
||||
## 八、接口返回内容说明
|
||||
|
||||
请求:
|
||||
|
||||
```http
|
||||
POST /api/admin/studio/upload-credentials
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer <admin-token>
|
||||
|
||||
{
|
||||
"fileName": "demo.jpg",
|
||||
"contentType": "image/jpeg",
|
||||
"assetType": "gallery"
|
||||
}
|
||||
```
|
||||
|
||||
正常返回会包含:
|
||||
|
||||
- `uploadUrl`
|
||||
- `fileUrl`
|
||||
- `key`
|
||||
- `assetType`
|
||||
- `expiresAt`
|
||||
- `formData`
|
||||
|
||||
`formData` 里会有这些字段:
|
||||
|
||||
- `key`
|
||||
- `policy`
|
||||
- `success_action_status`
|
||||
- `Content-Type`
|
||||
- `q-sign-algorithm`
|
||||
- `q-ak`
|
||||
- `q-key-time`
|
||||
- `q-sign-time`
|
||||
- `q-signature`
|
||||
|
||||
这就是小程序直传需要的全部内容。
|
||||
|
||||
## 九、怎么验证是否配置正确
|
||||
|
||||
### 1. 接口层验证
|
||||
|
||||
调用 `POST /api/admin/studio/upload-credentials`。
|
||||
|
||||
如果成功,说明:
|
||||
|
||||
- 服务端长期密钥有效
|
||||
- 服务端已经能正确签发 policy
|
||||
|
||||
### 2. 上传层验证
|
||||
|
||||
在管理中心上传一张图,检查:
|
||||
|
||||
1. COS Bucket 下是否出现对象
|
||||
2. 返回的 `fileUrl` 浏览器是否能直接访问
|
||||
3. 保存工作室设置后首页是否显示该图
|
||||
|
||||
### 3. 失败时怎么定位
|
||||
|
||||
如果 `upload-credentials` 接口失败,优先检查:
|
||||
|
||||
- `COS_SECRET_ID` / `COS_SECRET_KEY` 是否正确
|
||||
- `COS_BUCKET` / `COS_REGION` 是否正确
|
||||
- 服务端是否已经加载最新环境变量
|
||||
|
||||
如果接口成功但上传失败,优先检查:
|
||||
|
||||
- 小程序 `uploadFile 合法域名` 是否正确
|
||||
- Bucket 权限策略是否允许当前长期密钥上传到该前缀
|
||||
- `Content-Type` 是否被策略条件限制住
|
||||
|
||||
如果上传成功但图片打不开,优先检查:
|
||||
|
||||
- Bucket 或图片域名是否可公网访问
|
||||
- `COS_PUBLIC_BASE_URL` 是否正确
|
||||
- 小程序 `downloadFile 合法域名` 是否正确
|
||||
|
||||
## 十、当前实现的边界
|
||||
|
||||
当前仓库实现边界如下:
|
||||
|
||||
- 只支持 `jpg`、`jpeg`、`png`、`webp`、`heic`、`heif`
|
||||
- 单次上传大小上限 `10MB`
|
||||
- 只支持普通表单直传,不支持分片上传
|
||||
- 删除工作室图片时,只会从数据库配置里移除 URL,不会删除 COS 历史对象
|
||||
|
||||
最后一条是故意保守设计。
|
||||
原因很简单:
|
||||
|
||||
- 先保证配置删除安全
|
||||
- 避免误删真实文件
|
||||
|
||||
如果以后要做“删配置时同步删对象”,那时再单独加 `DeleteObject` 权限。
|
||||
|
||||
## 十一、初始化工作室画廊
|
||||
|
||||
如果你要把现在手工写死的图片 URL 一次性写入数据库,执行:
|
||||
|
||||
```bash
|
||||
pnpm --filter @mp-pilates/server studio:seed-gallery
|
||||
```
|
||||
|
||||
脚本文件:
|
||||
|
||||
- `packages/server/prisma/update-studio-gallery.ts`
|
||||
|
||||
## 十二、建议的生产做法
|
||||
|
||||
如果你后面要长期维护,建议:
|
||||
|
||||
1. 图片单独放一个 Bucket。
|
||||
2. 长期密钥不要直接用主账号,换成专用 CAM 用户。
|
||||
3. 对专用 CAM 用户只给 `mp/studio/*` 前缀上传权限。
|
||||
4. 用 CDN 域名作为 `COS_PUBLIC_BASE_URL`。
|
||||
|
||||
这样后面扩展、迁移、审计都会更稳。
|
||||
29
docs/lesson-supplement.md
Normal file
29
docs/lesson-supplement.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# 历史课程补录
|
||||
|
||||
入口:会员档案 → 上课情况 → 补录上课。一次填写 1–999 节,可备注,默认仅补记录,也可选择本人有限次会员卡扣次(含已过期卡)。不限次卡无需扣次;余额不足阻止提交。
|
||||
|
||||
一批补录展示为「系统补录 · 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 测试依赖缺失:未提供 FlashSaleService,8 项失败;其余已执行用例通过。
|
||||
- 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`,使依赖安装或迁移失败时停止发布。
|
||||
@@ -22,5 +22,6 @@
|
||||
"@prisma/engines",
|
||||
"prisma"
|
||||
]
|
||||
}
|
||||
},
|
||||
"version": "0.0.1"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
<view class="about-section">
|
||||
<view class="section-header">
|
||||
<view>
|
||||
<text class="section-eyebrow">Teacher Spotlight</text>
|
||||
<text class="section-title">老师介绍</text>
|
||||
</view>
|
||||
<text class="section-more" @tap="goToDetail">查看详情</text>
|
||||
@@ -12,9 +11,7 @@
|
||||
<view class="teacher-main">
|
||||
<view class="cover-wrap">
|
||||
<image class="teacher-cover" :src="teacher.avatar" mode="aspectFill" />
|
||||
<view class="cover-badge">
|
||||
<text class="cover-badge-text">{{ teacher.badges[0] }}</text>
|
||||
</view>
|
||||
|
||||
</view>
|
||||
|
||||
<view class="teacher-content">
|
||||
@@ -22,25 +19,20 @@
|
||||
<view>
|
||||
<view class="name-row">
|
||||
<text class="teacher-name">{{ teacher.name }}</text>
|
||||
<text class="teacher-tag">{{ teacher.badges[1] }}</text>
|
||||
|
||||
</view>
|
||||
<text class="teacher-title">{{ teacher.title }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="specialty-row">
|
||||
<text v-for="item in teacher.specialties" :key="item" class="specialty-pill">{{ item }}</text>
|
||||
<text class="specialty-text">{{ teacher.specialties.join(' · ') }}</text>
|
||||
</view>
|
||||
|
||||
<text class="teacher-intro">{{ teacher.intro }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="credential-box">
|
||||
<text class="credential-label">认证背景</text>
|
||||
<text class="credential-text">{{ certificationSummary }}</text>
|
||||
</view>
|
||||
|
||||
<view class="action-row">
|
||||
<view class="secondary-btn" @tap.stop="goToDetail">
|
||||
<text class="secondary-btn-text">查看详情</text>
|
||||
@@ -54,13 +46,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { irisProfile } from '../utils/teacher'
|
||||
|
||||
const teacher = irisProfile
|
||||
|
||||
const certificationSummary = computed(() => teacher.certifications.slice(0, 2).join(' · '))
|
||||
|
||||
function goToDetail() {
|
||||
uni.navigateTo({ url: `/pages/teacher/detail?id=${teacher.id}` })
|
||||
}
|
||||
@@ -71,209 +60,25 @@ function goToBooking() {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.about-section {
|
||||
padding: 20rpx 24rpx 0;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 18rpx;
|
||||
}
|
||||
|
||||
.section-eyebrow {
|
||||
display: block;
|
||||
font-size: 20rpx;
|
||||
letter-spacing: 3rpx;
|
||||
text-transform: uppercase;
|
||||
color: #b99b8c;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
color: #2f2723;
|
||||
}
|
||||
|
||||
.section-more {
|
||||
font-size: 24rpx;
|
||||
color: #c36d52;
|
||||
padding: 10rpx 0;
|
||||
}
|
||||
|
||||
.teacher-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18rpx;
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(226, 198, 179, 0.42), transparent 32%),
|
||||
linear-gradient(135deg, #fffdfb 0%, #f8f2ee 46%, #f2e8e2 100%);
|
||||
border-radius: 30rpx;
|
||||
padding: 22rpx;
|
||||
box-shadow: 0 18rpx 38rpx rgba(126, 98, 84, 0.09);
|
||||
}
|
||||
|
||||
.teacher-main {
|
||||
display: flex;
|
||||
gap: 22rpx;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.cover-wrap {
|
||||
position: relative;
|
||||
width: 188rpx;
|
||||
height: 248rpx;
|
||||
border-radius: 24rpx;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background: #eadfd7;
|
||||
}
|
||||
|
||||
.teacher-cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cover-badge {
|
||||
position: absolute;
|
||||
left: 14rpx;
|
||||
bottom: 14rpx;
|
||||
border-radius: 999rpx;
|
||||
padding: 8rpx 16rpx;
|
||||
background: rgba(41, 34, 30, 0.74);
|
||||
backdrop-filter: blur(12rpx);
|
||||
}
|
||||
|
||||
.cover-badge-text {
|
||||
font-size: 20rpx;
|
||||
font-weight: 600;
|
||||
color: #fff7f3;
|
||||
}
|
||||
|
||||
.teacher-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.teacher-heading {
|
||||
margin-bottom: 14rpx;
|
||||
}
|
||||
|
||||
.name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.teacher-name {
|
||||
font-size: 38rpx;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
color: #2e2521;
|
||||
}
|
||||
|
||||
.teacher-tag {
|
||||
font-size: 18rpx;
|
||||
line-height: 1;
|
||||
color: #a85d44;
|
||||
background: rgba(221, 150, 118, 0.18);
|
||||
border-radius: 999rpx;
|
||||
padding: 8rpx 12rpx;
|
||||
}
|
||||
|
||||
.teacher-title {
|
||||
font-size: 24rpx;
|
||||
color: #7f6659;
|
||||
}
|
||||
|
||||
.specialty-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10rpx;
|
||||
margin-bottom: 14rpx;
|
||||
}
|
||||
|
||||
.specialty-pill {
|
||||
padding: 8rpx 16rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(92, 126, 151, 0.12);
|
||||
color: #5a7a8b;
|
||||
font-size: 20rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.teacher-intro {
|
||||
font-size: 22rpx;
|
||||
line-height: 1.7;
|
||||
color: #5e5048;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.credential-box {
|
||||
padding: 16rpx 18rpx;
|
||||
border-radius: 18rpx;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
border: 1rpx solid rgba(190, 161, 145, 0.22);
|
||||
}
|
||||
|
||||
.credential-label {
|
||||
display: block;
|
||||
font-size: 18rpx;
|
||||
letter-spacing: 2rpx;
|
||||
color: #b19486;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.credential-text {
|
||||
font-size: 20rpx;
|
||||
line-height: 1.6;
|
||||
color: #6a5a51;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.secondary-btn,
|
||||
.primary-btn {
|
||||
height: 72rpx;
|
||||
border-radius: 999rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.secondary-btn {
|
||||
width: 148rpx;
|
||||
border: 1rpx solid rgba(139, 113, 99, 0.24);
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
flex: 1;
|
||||
background: linear-gradient(135deg, #ff7654 0%, #ff4d38 100%);
|
||||
box-shadow: 0 14rpx 24rpx rgba(255, 92, 69, 0.24);
|
||||
}
|
||||
|
||||
.secondary-btn-text {
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
color: #684d40;
|
||||
}
|
||||
|
||||
.primary-btn-text {
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
.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>
|
||||
|
||||
@@ -36,15 +36,12 @@
|
||||
<!-- Membership card selection -->
|
||||
<view class="card-section">
|
||||
<view class="section-label-row">
|
||||
<text class="section-label">选择扣课会员卡</text>
|
||||
<text class="section-label">选择会员卡</text>
|
||||
</view>
|
||||
|
||||
<!-- Single membership -->
|
||||
<view v-if="memberships.length === 1" class="card-single">
|
||||
<view class="card-item selected">
|
||||
<view class="card-icon-wrap">
|
||||
<text class="card-icon">💳</text>
|
||||
</view>
|
||||
<view class="card-info">
|
||||
<text class="card-name">{{ memberships[0].cardType.name }}</text>
|
||||
<text class="card-remain" v-if="memberships[0].remainingTimes !== null">
|
||||
@@ -61,7 +58,7 @@
|
||||
</view>
|
||||
|
||||
<!-- Multiple memberships picker -->
|
||||
<view v-else-if="memberships.length > 1" class="card-picker-wrap">
|
||||
<scroll-view v-else-if="memberships.length > 1" class="card-picker-wrap" scroll-y>
|
||||
<view
|
||||
v-for="m in memberships"
|
||||
:key="m.id"
|
||||
@@ -69,9 +66,6 @@
|
||||
:class="{ selected: selectedMembershipId === m.id }"
|
||||
@tap="selectedMembershipId = m.id"
|
||||
>
|
||||
<view class="card-icon-wrap">
|
||||
<text class="card-icon">💳</text>
|
||||
</view>
|
||||
<view class="card-info">
|
||||
<text class="card-name">{{ m.cardType.name }}</text>
|
||||
<text class="card-remain" v-if="m.remainingTimes !== null">
|
||||
@@ -85,7 +79,7 @@
|
||||
<text class="check-icon">✓</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- No memberships fallback (should not normally appear) -->
|
||||
<view v-else class="no-card-tip">
|
||||
@@ -96,7 +90,9 @@
|
||||
<!-- Deduction tip -->
|
||||
<view class="deduction-tip" v-if="selectedMembership">
|
||||
<text class="deduction-text">
|
||||
确认后将从「{{ selectedMembership.cardType.name }}」扣除 1 次课时
|
||||
{{ selectedMembership.remainingTimes === null
|
||||
? `「${selectedMembership.cardType.name}」有效期内不限次`
|
||||
: `确认后将从「${selectedMembership.cardType.name}」扣除 1 次课时` }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
@@ -108,6 +104,8 @@
|
||||
<button
|
||||
class="btn-confirm"
|
||||
:class="{ disabled: !selectedMembershipId }"
|
||||
:disabled="!selectedMembershipId || requestingSubscribe"
|
||||
:loading="requestingSubscribe"
|
||||
@tap="handleConfirm"
|
||||
>
|
||||
<text class="btn-confirm-text">确认预约</text>
|
||||
@@ -141,8 +139,8 @@ const requestingSubscribe = ref(false)
|
||||
watch(
|
||||
[() => props.visible, () => props.memberships],
|
||||
([visible, memberships]) => {
|
||||
if (visible && memberships.length > 0) {
|
||||
selectedMembershipId.value = memberships[0].id
|
||||
if (visible) {
|
||||
selectedMembershipId.value = memberships[0]?.id ?? ''
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -185,261 +183,45 @@ function handleMaskTap() {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.popup-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.popup-panel {
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border-radius: 32rpx 32rpx 0 0;
|
||||
padding: 32rpx 32rpx calc(32rpx + env(safe-area-inset-bottom));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.popup-title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: $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;
|
||||
}
|
||||
|
||||
.popup-mask { position: fixed; inset: 0; background: rgba(56, 48, 42, 0.32); z-index: 1000; display: flex; align-items: flex-end; justify-content: center; }
|
||||
.popup-panel { width: 100%; box-sizing: border-box; background: #fbf9f6; border-radius: 36rpx 36rpx 0 0; padding: 28rpx 32rpx calc(24rpx + env(safe-area-inset-bottom)); }
|
||||
.popup-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24rpx; }
|
||||
.popup-title { font-size: 32rpx; font-weight: 500; color: #514943; }
|
||||
.close-btn { width: 60rpx; height: 60rpx; display: flex; align-items: center; justify-content: center; }
|
||||
.close-icon { font-size: 26rpx; color: #8b817b; }
|
||||
.info-section { padding: 24rpx; background: #f1e8e1; border-radius: 24rpx; display: flex; flex-direction: column; gap: 14rpx; }
|
||||
.info-row { display: flex; align-items: center; gap: 20rpx; }
|
||||
.info-label { font-size: 24rpx; color: #8b7b70; width: 64rpx; flex-shrink: 0; }
|
||||
.info-value { font-size: 26rpx; color: #6f5c50; font-weight: 400; }
|
||||
.divider { height: 24rpx; }
|
||||
.card-section { display: flex; flex-direction: column; gap: 16rpx; }
|
||||
.section-label { font-size: 26rpx; color: #514943; font-weight: 500; }
|
||||
.card-picker-wrap { height: 250rpx; }
|
||||
.card-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 24rpx 20rpx;
|
||||
border-radius: 16rpx;
|
||||
border: 2rpx solid $primary-border;
|
||||
background: $primary-bg;
|
||||
gap: 20rpx;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
|
||||
&.selected {
|
||||
border-color: $primary-dark;
|
||||
background: $primary-selected-bg;
|
||||
}
|
||||
display: flex; align-items: center; padding: 22rpx 24rpx; border-radius: 24rpx;
|
||||
border: 2rpx solid #eee8e3; background: #fff; gap: 20rpx; box-sizing: border-box;
|
||||
&.selected { border-color: #a5b8ab; background: #f0f4ee; }
|
||||
}
|
||||
|
||||
.card-icon-wrap {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
border-radius: 14rpx;
|
||||
background: linear-gradient(135deg, $primary-color, $primary-dark);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
font-size: 32rpx;
|
||||
}
|
||||
|
||||
.card-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6rpx;
|
||||
}
|
||||
|
||||
.card-name {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.card-remain {
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.check-mark {
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
border-radius: 50%;
|
||||
background: $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;
|
||||
}
|
||||
|
||||
.card-picker-wrap .card-item { margin-bottom: 12rpx; }
|
||||
.card-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
|
||||
.card-name { font-size: 26rpx; font-weight: 400; color: #514943; overflow-wrap: anywhere; }
|
||||
.card-remain { font-size: 22rpx; color: #8b817b; }
|
||||
.check-mark { width: 36rpx; height: 36rpx; border-radius: 50%; background: #6b8276; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
.check-icon { font-size: 22rpx; color: #fff; }
|
||||
.no-card-tip { padding: 24rpx; text-align: center; }
|
||||
.no-card-text { font-size: 24rpx; color: #8b817b; }
|
||||
.deduction-tip { padding: 18rpx 4rpx; }
|
||||
.deduction-text { font-size: 22rpx; color: #8b817b; line-height: 1.6; }
|
||||
.action-row { display: flex; gap: 20rpx; margin-top: 12rpx; }
|
||||
.btn-outline { flex: 1; height: 88rpx; border-radius: 999rpx; background: #f0eae4; display: flex; align-items: center; justify-content: center; }
|
||||
.btn-outline-text { font-size: 28rpx; color: #78675c; font-weight: 400; }
|
||||
.btn-confirm {
|
||||
flex: 2;
|
||||
height: 88rpx;
|
||||
border-radius: 44rpx;
|
||||
background: linear-gradient(135deg, $primary-color, $primary-dark);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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;
|
||||
}
|
||||
flex: 2; height: 88rpx; padding: 0; margin: 0; border: none; line-height: 1;
|
||||
border-radius: 999rpx; background: #6b8276;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
&::after { border: none; }
|
||||
&:active { background: #597264; }
|
||||
&.disabled { background: #d9dfd8; }
|
||||
}
|
||||
.btn-confirm-text { font-size: 28rpx; color: #fff; font-weight: 500; .disabled & { color: #677467; } }
|
||||
</style>
|
||||
|
||||
@@ -14,27 +14,36 @@
|
||||
<!-- Circular logo -->
|
||||
<view class="logo-circle">
|
||||
<image
|
||||
v-if="logoImage"
|
||||
class="logo-img"
|
||||
:src="logoImage"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view v-else class="logo-placeholder">
|
||||
<text>{{ studioName.slice(0, 1) || 'F' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Studio name -->
|
||||
<text class="studio-name">Focus Core</text>
|
||||
<text class="studio-name">{{ studioName }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { StudioConfig } from '@mp-pilates/shared'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
studioInfo: StudioConfig | null
|
||||
}>()
|
||||
|
||||
const bannerImage = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/images/bannerBg.jpg'
|
||||
const logoImage = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/images/logo.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>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -94,10 +103,16 @@ const logoImage = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/im
|
||||
}
|
||||
|
||||
.logo-placeholder {
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
border-radius: 50%;
|
||||
font-size: 64rpx;
|
||||
font-weight: 800;
|
||||
color: #333;
|
||||
letter-spacing: 4rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.studio-name {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<!-- Section header -->
|
||||
<view class="section-header">
|
||||
<text class="section-title">会员卡</text>
|
||||
<text class="section-action" @tap="goToAllCards">全部</text>
|
||||
<text class="section-action" @tap="goToAllCards">全部 ›</text>
|
||||
</view>
|
||||
|
||||
<!-- Loading skeleton -->
|
||||
@@ -30,16 +30,26 @@
|
||||
class="card-row"
|
||||
@tap="goToDetail(card.id)"
|
||||
>
|
||||
<!-- Card Cover — clean minimal design -->
|
||||
<view class="card-cover" :class="getCardCoverClass(card.type)">
|
||||
<view class="cover-deco cover-deco--1" />
|
||||
<view class="cover-deco cover-deco--2" />
|
||||
<!-- Card Cover — image if available, gradient fallback -->
|
||||
<view class="card-cover" :class="card.coverUrl ? '' : getCardCoverClass(card.type)">
|
||||
<image
|
||||
v-if="card.coverUrl"
|
||||
class="card-cover-img"
|
||||
:src="card.coverUrl"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<template v-else>
|
||||
<text class="cover-label">{{ card.type === CardTypeCategory.TRIAL ? '体验' : card.type === CardTypeCategory.DURATION ? '期限卡' : '次卡' }}</text>
|
||||
</template>
|
||||
</view>
|
||||
|
||||
<!-- Card info — aligns with card-cover height -->
|
||||
<view class="card-info">
|
||||
<view class="info-top">
|
||||
<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>
|
||||
</view>
|
||||
<view class="info-bottom">
|
||||
@@ -72,15 +82,32 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import type { CardType } from '@mp-pilates/shared'
|
||||
import { CardTypeCategory } from '@mp-pilates/shared'
|
||||
import { get } from '../utils/request'
|
||||
import { formatPrice, getCardCoverClass } from '../utils/format'
|
||||
import { useUserStore } from '../stores/user'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const cardTypes = ref<CardType[]>([])
|
||||
const loading = ref(false)
|
||||
const hasLoaded = ref(false)
|
||||
|
||||
const renewableCardTypeIds = computed(() => {
|
||||
const ids = new Set<string>()
|
||||
for (const membership of userStore.memberships) {
|
||||
if (membership.cardType.type !== CardTypeCategory.TRIAL) {
|
||||
ids.add(membership.cardTypeId)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
})
|
||||
|
||||
function isRenewable(card: CardType): boolean {
|
||||
return card.type !== CardTypeCategory.TRIAL && renewableCardTypeIds.value.has(card.id)
|
||||
}
|
||||
|
||||
async function fetchCardTypes() {
|
||||
// Stale-While-Revalidate: only show skeleton on first load
|
||||
// Subsequent refreshes silently update data in background
|
||||
@@ -118,247 +145,36 @@ function goToAllCards() {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.card-shop {
|
||||
background: #ffffff;
|
||||
margin: 16rpx 24rpx 0;
|
||||
padding-bottom: 20rpx;
|
||||
border-radius: 20rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* ── Section header ── */
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 32rpx 32rpx 20rpx;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.section-action {
|
||||
font-size: 26rpx;
|
||||
color: #999;
|
||||
padding: 8rpx 24rpx;
|
||||
border: 1rpx solid #ddd;
|
||||
border-radius: 24rpx;
|
||||
}
|
||||
|
||||
/* ── Card list ── */
|
||||
.card-list {
|
||||
padding: 0 32rpx;
|
||||
}
|
||||
|
||||
.card-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
padding: 16rpx 0;
|
||||
border-bottom: 1rpx solid rgba($brand-color, 0.08);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════
|
||||
CARD COVER — Clean minimal design
|
||||
══════════════════════════════════════════════════════════ */
|
||||
.card-cover {
|
||||
width: 200rpx;
|
||||
height: 130rpx;
|
||||
border-radius: 16rpx;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Decorative circles */
|
||||
.cover-deco {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
|
||||
&--1 {
|
||||
width: 100rpx;
|
||||
height: 100rpx;
|
||||
top: -30rpx;
|
||||
right: -20rpx;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
&--2 {
|
||||
width: 70rpx;
|
||||
height: 70rpx;
|
||||
bottom: -20rpx;
|
||||
left: -10rpx;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
/* Card cover backgrounds */
|
||||
.cover--times {
|
||||
background: linear-gradient(135deg, #E8D5C4 0%, #D4BFA8 100%);
|
||||
}
|
||||
|
||||
.cover--duration {
|
||||
background: linear-gradient(135deg, #D8C8DC 0%, #C4AECB 100%);
|
||||
}
|
||||
|
||||
.cover--trial {
|
||||
background: linear-gradient(135deg, #C8D8D2 0%, #A9C4BC 100%);
|
||||
}
|
||||
|
||||
/* ── Card info — matches card-cover height ── */
|
||||
.card-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 130rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.info-top {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 4rpx;
|
||||
}
|
||||
|
||||
.info-bottom {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.card-name {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: $text-primary;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.5rpx;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.card-validity {
|
||||
font-size: 23rpx;
|
||||
color: $text-secondary;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.card-times {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4rpx;
|
||||
}
|
||||
|
||||
.card-times-value {
|
||||
font-size: 34rpx;
|
||||
font-weight: 800;
|
||||
color: $brand-color;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.card-times-unit {
|
||||
font-size: 20rpx;
|
||||
color: $text-secondary;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.price-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6rpx;
|
||||
}
|
||||
|
||||
.price-current {
|
||||
font-size: 32rpx;
|
||||
font-weight: 800;
|
||||
color: $brand-color;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.price-original {
|
||||
font-size: 20rpx;
|
||||
color: $text-hint;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
.card-shop { margin: 36rpx 32rpx 0; }
|
||||
.section-header { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; margin-bottom: 20rpx; }
|
||||
.section-title { font-size: 30rpx; font-weight: 500; color: #514943; }
|
||||
.section-action { font-size: 23rpx; color: #8b817b; padding: 8rpx 0; }
|
||||
.card-list { padding: 0 24rpx; border-radius: 28rpx; background: #fff; border: 1rpx solid #eee8e3; }
|
||||
.card-row { display: flex; align-items: center; gap: 20rpx; padding: 26rpx 0; border-bottom: 1rpx solid #f0ebe6; &:last-child { border: none; } }
|
||||
.card-cover { width: 132rpx; height: 148rpx; border-radius: 16rpx; overflow: hidden; flex-shrink: 0; display: flex; align-items: center; justify-content: center; background: #f0e6de; }
|
||||
.card-cover-img { width: 100%; height: 100%; }
|
||||
.cover--times { background: #f0e6de; }
|
||||
.cover--duration { background: #e9ede5; }
|
||||
.cover--trial { background: #f2e4e0; }
|
||||
.cover-label { font-family: 'Songti SC', 'STSong', serif; font-size: 28rpx; color: #857062; letter-spacing: 3rpx; }
|
||||
.card-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 14rpx; }
|
||||
.info-top { display: flex; flex-direction: column; gap: 8rpx; }
|
||||
.card-name-row { display: flex; align-items: baseline; gap: 8rpx; }
|
||||
.card-name { min-width: 0; font-size: 27rpx; font-weight: 400; color: #514943; line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.renew-tag { flex-shrink: 0; font-size: 18rpx; color: #617d73; background: #edf2ec; padding: 3rpx 8rpx; border-radius: 6rpx; }
|
||||
.card-validity { font-size: 21rpx; color: #8b817b; }
|
||||
.info-bottom { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8rpx 16rpx; }
|
||||
.card-times { display: flex; align-items: baseline; gap: 4rpx; }
|
||||
.card-times-value { font-size: 24rpx; color: #6f655e; }
|
||||
.card-times-unit { font-size: 20rpx; color: #8b817b; }
|
||||
.price-row { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8rpx; }
|
||||
.price-current { font-size: 30rpx; font-weight: 500; color: #8b6c5b; font-variant-numeric: tabular-nums; }
|
||||
.price-original { font-size: 20rpx; color: #a59b93; text-decoration: line-through; }
|
||||
.card-arrow { font-size: 32rpx; color: #b2a79e; flex-shrink: 0; }
|
||||
.skeleton-card-cover { width: 132rpx; height: 148rpx; border-radius: 16rpx; flex-shrink: 0; }
|
||||
.skeleton-info { flex: 1; display: flex; flex-direction: column; gap: 16rpx; }
|
||||
.skeleton-line { height: 24rpx; width: 70%; border-radius: 8rpx; &--sub { width: 50%; } }
|
||||
.skeleton-line, .skeleton-card-cover { background: linear-gradient(90deg, #f0eae5 25%, #faf7f3 50%, #f0eae5 75%); background-size: 400% 100%; animation: shimmer 1.4s infinite; }
|
||||
.empty-state { padding: 48rpx 24rpx; border-radius: 24rpx; background: #f3efea; text-align: center; }
|
||||
.empty-text { font-size: 24rpx; color: #8b817b; }
|
||||
</style>
|
||||
|
||||
@@ -39,7 +39,14 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
function handleBack() {
|
||||
uni.navigateBack({ delta: 1 })
|
||||
const pages = getCurrentPages()
|
||||
|
||||
if (pages.length > 1) {
|
||||
uni.navigateBack({ delta: 1 })
|
||||
return
|
||||
}
|
||||
|
||||
uni.switchTab({ url: '/pages/home/index' })
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
<template>
|
||||
<view class="date-selector">
|
||||
<view class="date-selector" :class="`date-selector--${variant}`">
|
||||
<scroll-view class="scroll" scroll-x enhanced :show-scrollbar="false">
|
||||
<view class="track">
|
||||
<view
|
||||
v-for="item in dateRange"
|
||||
:key="item.date"
|
||||
class="date-item"
|
||||
:class="{ active: item.date === modelValue, today: item.isToday }"
|
||||
:class="[
|
||||
`date-item--${variant}`,
|
||||
{ active: item.date === modelValue, today: item.isToday },
|
||||
]"
|
||||
@tap="handleSelect(item.date)"
|
||||
>
|
||||
<text class="weekday">{{ item.isToday ? '今天' : item.weekday }}</text>
|
||||
<text class="day">{{ getDayNumber(item.date) }}</text>
|
||||
<text class="month">{{ getMonthNumber(item.date) }}月</text>
|
||||
<text v-if="variant !== 'soft' || getDayNumber(item.date) === '1'" class="month">{{ getMonthNumber(item.date) }}月</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
@@ -25,6 +28,7 @@ import { getDateRange } from '../utils/format'
|
||||
|
||||
interface Props {
|
||||
modelValue: string
|
||||
variant?: 'default' | 'booking' | 'soft'
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@@ -47,6 +51,8 @@ function handleSelect(date: string) {
|
||||
emit('update:modelValue', date)
|
||||
emit('select', date)
|
||||
}
|
||||
|
||||
const variant = computed(() => props.variant ?? 'default')
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -55,6 +61,11 @@ function handleSelect(date: string) {
|
||||
padding: 16rpx 0 20rpx;
|
||||
border-bottom: 1rpx solid $primary-border;
|
||||
|
||||
&.date-selector--booking {
|
||||
background: rgba(252, 250, 248, 0.96);
|
||||
border-bottom-color: rgba(192, 154, 137, 0.12);
|
||||
}
|
||||
|
||||
.scroll {
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
@@ -121,6 +132,60 @@ function handleSelect(date: string) {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
&.date-item--booking {
|
||||
background: rgba(247, 242, 238, 0.88);
|
||||
border: 1rpx solid rgba(192, 154, 137, 0.08);
|
||||
|
||||
.weekday {
|
||||
color: #9d8b83;
|
||||
}
|
||||
|
||||
.day {
|
||||
color: #3a2e2a;
|
||||
}
|
||||
|
||||
.month {
|
||||
color: #b7a79f;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: linear-gradient(135deg, #d7beb1, #b98f7d);
|
||||
box-shadow: 0 12rpx 28rpx rgba(143, 103, 89, 0.16);
|
||||
|
||||
.weekday,
|
||||
.day,
|
||||
.month {
|
||||
color: #fffaf7;
|
||||
}
|
||||
}
|
||||
|
||||
&.today:not(.active) {
|
||||
.weekday {
|
||||
color: #8f6759;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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>
|
||||
|
||||
@@ -3,11 +3,8 @@
|
||||
<!-- Section header -->
|
||||
<view class="section-header">
|
||||
<view class="header-left">
|
||||
<view class="flash-icon-wrap">
|
||||
<view class="flash-icon-clock" />
|
||||
</view>
|
||||
<text class="section-title">限时秒杀</text>
|
||||
<view v-if="hasOngoing" class="live-dot" />
|
||||
<text v-if="hasOngoing" class="live-note">进行中</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -155,284 +152,31 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.flash-sale-section {
|
||||
background: #fff;
|
||||
margin: 16rpx 24rpx 0;
|
||||
padding-bottom: 24rpx;
|
||||
border-radius: 20rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* ── Section header ── */
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 28rpx 32rpx 16rpx;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.flash-icon-wrap {
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
border-radius: 12rpx;
|
||||
background: linear-gradient(135deg, #D4A59A, #C08B7E);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* CSS-drawn clock icon */
|
||||
.flash-icon-clock {
|
||||
width: 24rpx;
|
||||
height: 24rpx;
|
||||
border: 3rpx solid #fff;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 2rpx;
|
||||
height: 9rpx;
|
||||
background: #fff;
|
||||
transform-origin: bottom center;
|
||||
transform: translate(-50%, -100%) rotate(0deg);
|
||||
border-radius: 1rpx;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 2rpx;
|
||||
height: 7rpx;
|
||||
background: #fff;
|
||||
transform-origin: bottom center;
|
||||
transform: translate(-50%, -100%) rotate(90deg);
|
||||
border-radius: 1rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.live-dot {
|
||||
width: 12rpx;
|
||||
height: 12rpx;
|
||||
border-radius: 50%;
|
||||
background: #C08B7E;
|
||||
animation: pulse 1.5s ease infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.4; transform: scale(0.8); }
|
||||
}
|
||||
|
||||
/* ── Horizontal scroll ── */
|
||||
.flash-scroll {
|
||||
padding-left: 32rpx;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.flash-card-list {
|
||||
display: inline-flex;
|
||||
gap: 20rpx;
|
||||
padding-right: 32rpx;
|
||||
}
|
||||
|
||||
/* ── Flash card ── */
|
||||
.flash-card {
|
||||
width: 340rpx;
|
||||
border-radius: 20rpx;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 4rpx 20rpx rgba(192, 139, 126, 0.18);
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.card--ongoing { box-shadow: 0 6rpx 28rpx rgba(192, 139, 126, 0.28); }
|
||||
.card--upcoming { opacity: 0.95; }
|
||||
.card--soldout { opacity: 0.7; }
|
||||
.card--ended { opacity: 0.5; }
|
||||
|
||||
/* Card top gradient — warm blush tones */
|
||||
.card-top {
|
||||
padding: 20rpx 20rpx 16rpx;
|
||||
background: linear-gradient(135deg, #D4A59A 0%, #C9948A 40%, #B5836E 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.card--upcoming .card-top {
|
||||
background: linear-gradient(135deg, #8FA89A 0%, #7BA5A0 100%);
|
||||
}
|
||||
|
||||
.card--soldout .card-top,
|
||||
.card--ended .card-top {
|
||||
background: linear-gradient(135deg, #C4BAB0, #AEA49A);
|
||||
}
|
||||
|
||||
/* Phase badge */
|
||||
.phase-badge {
|
||||
align-self: flex-start;
|
||||
padding: 4rpx 14rpx;
|
||||
border-radius: 12rpx;
|
||||
}
|
||||
|
||||
.badge--ongoing { background: rgba(255, 255, 255, 0.3); }
|
||||
.badge--upcoming { background: rgba(255, 255, 255, 0.25); }
|
||||
.badge--inactive { background: rgba(0, 0, 0, 0.1); }
|
||||
|
||||
.phase-badge-text {
|
||||
font-size: 20rpx;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Countdown */
|
||||
.countdown-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.countdown-label {
|
||||
font-size: 20rpx;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.countdown-blocks {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4rpx;
|
||||
}
|
||||
|
||||
.cd-block {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
color: #fff;
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
padding: 4rpx 8rpx;
|
||||
border-radius: 6rpx;
|
||||
font-family: 'DIN Alternate', monospace;
|
||||
min-width: 36rpx;
|
||||
text-align: center;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.cd-sep {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
font-size: 20rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Card body */
|
||||
.card-body {
|
||||
padding: 20rpx;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: $text-primary;
|
||||
line-height: 1.3;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-type-name {
|
||||
font-size: 22rpx;
|
||||
color: $text-hint;
|
||||
}
|
||||
|
||||
/* Price area */
|
||||
.price-area {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12rpx;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.flash-price-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.flash-currency {
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
color: #B5725E;
|
||||
}
|
||||
|
||||
.flash-price {
|
||||
font-size: 40rpx;
|
||||
font-weight: 800;
|
||||
color: #B5725E;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.original-price {
|
||||
font-size: 22rpx;
|
||||
color: #ccc;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
/* Stock area */
|
||||
.stock-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6rpx;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.stock-bar {
|
||||
height: 10rpx;
|
||||
background: #f5f0ed;
|
||||
border-radius: 5rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stock-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #D4A59A, #C08B7E);
|
||||
border-radius: 5rpx;
|
||||
transition: width 0.3s;
|
||||
|
||||
&--hot {
|
||||
animation: stockPulse 2s ease infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes stockPulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
.stock-text {
|
||||
font-size: 20rpx;
|
||||
color: $text-hint;
|
||||
}
|
||||
.flash-sale-section { margin: 36rpx 32rpx 0; }
|
||||
.section-header { margin-bottom: 20rpx; }
|
||||
.header-left { display: flex; align-items: baseline; justify-content: space-between; gap: 16rpx; }
|
||||
.section-title { font-size: 30rpx; font-weight: 500; color: #514943; }
|
||||
.live-note { font-size: 22rpx; color: #9b7b66; }
|
||||
.flash-scroll { width: 100%; white-space: nowrap; }
|
||||
.flash-card-list { display: inline-flex; gap: 20rpx; }
|
||||
.flash-card { width: 400rpx; border-radius: 28rpx; overflow: hidden; border: 1rpx solid #e7ded5; flex-shrink: 0; display: inline-flex; flex-direction: column; white-space: normal; background: #fff; }
|
||||
.card-top { padding: 20rpx 24rpx; background: #f1e6de; display: flex; flex-direction: column; gap: 12rpx; }
|
||||
.card--upcoming .card-top { background: #eaf0e8; }
|
||||
.card--soldout .card-top, .card--ended .card-top { background: #eeeae5; }
|
||||
.phase-badge-text { font-size: 22rpx; color: #7c6656; }
|
||||
.countdown-row, .countdown-blocks { display: flex; align-items: baseline; gap: 8rpx; }
|
||||
.countdown-label { font-size: 20rpx; color: #8b817b; }
|
||||
.cd-block, .cd-sep { font-size: 24rpx; color: #7c6656; font-variant-numeric: tabular-nums; }
|
||||
.card-body { padding: 24rpx; display: flex; flex-direction: column; gap: 10rpx; }
|
||||
.card-title { font-size: 28rpx; font-weight: 500; color: #514943; line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.card-type-name { font-size: 22rpx; color: #8b817b; }
|
||||
.price-area { display: flex; align-items: baseline; flex-wrap: wrap; gap: 12rpx; margin-top: 8rpx; }
|
||||
.flash-price-row { display: flex; align-items: baseline; gap: 4rpx; }
|
||||
.flash-currency { font-size: 22rpx; color: #8b6c5b; }
|
||||
.flash-price { font-size: 38rpx; font-weight: 500; color: #8b6c5b; }
|
||||
.original-price { font-size: 21rpx; color: #a59b93; text-decoration: line-through; }
|
||||
.stock-area { margin-top: 12rpx; display: flex; flex-direction: column; gap: 10rpx; }
|
||||
.stock-bar { height: 6rpx; background: #f3efea; border-radius: 6rpx; overflow: hidden; }
|
||||
.stock-fill { height: 100%; border-radius: 6rpx; background: #bea28e; }
|
||||
.stock-text { font-size: 20rpx; color: #8b817b; }
|
||||
</style>
|
||||
|
||||
40
packages/app/src/components/LessonSupplementList.vue
Normal file
40
packages/app/src/components/LessonSupplementList.vue
Normal 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>
|
||||
78
packages/app/src/components/MonthDivider.vue
Normal file
78
packages/app/src/components/MonthDivider.vue
Normal 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>
|
||||
382
packages/app/src/components/NextSessionHero.vue
Normal file
382
packages/app/src/components/NextSessionHero.vue
Normal 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>
|
||||
126
packages/app/src/components/PracticeActivityCard.vue
Normal file
126
packages/app/src/components/PracticeActivityCard.vue
Normal 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>
|
||||
@@ -1,32 +1,29 @@
|
||||
<template>
|
||||
<view class="profile-menu">
|
||||
<template v-for="item in menuItems" :key="item.key">
|
||||
<!-- Separator -->
|
||||
<view v-if="item.type === 'separator'" class="profile-menu__separator" />
|
||||
|
||||
<!-- Menu item -->
|
||||
<view
|
||||
v-else
|
||||
class="profile-menu__item"
|
||||
:class="{ 'profile-menu__item--admin': item.isAdmin }"
|
||||
hover-class="profile-menu__item--hover"
|
||||
hover-stay-time="150"
|
||||
@tap="handleTap(item)"
|
||||
>
|
||||
<view
|
||||
class="profile-menu__icon-wrap"
|
||||
:class="[
|
||||
`profile-menu__icon-wrap--${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 class="profile-menu__shortcuts">
|
||||
<view v-for="item in primaryItems" :key="item.key" class="profile-menu__shortcut"
|
||||
hover-class="profile-menu__item--hover" @tap="handleTap(item)">
|
||||
<view class="profile-menu__shortcut-head">
|
||||
<view class="profile-menu__icon" :class="'profile-menu__icon--' + item.key" />
|
||||
<text class="profile-menu__arrow">›</text>
|
||||
</view>
|
||||
<text class="profile-menu__shortcut-title">{{ item.title }}</text>
|
||||
<text class="profile-menu__shortcut-note">{{ !requireAuth ? '登录后查看' : item.key === 'membership' ? `${activeMembershipCount || 0} 张可用` : `${upcomingBookingCount || 0} 节待上` }}</text>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
|
||||
<slot />
|
||||
|
||||
<view class="profile-menu__links">
|
||||
<template v-for="item in secondaryItems" :key="item.key">
|
||||
<view v-if="item.type === 'separator'" class="profile-menu__separator" />
|
||||
<view v-else class="profile-menu__item" :class="{ 'profile-menu__item--admin': item.isAdmin }"
|
||||
hover-class="profile-menu__item--hover" hover-stay-time="150" @tap="handleTap(item)">
|
||||
<text class="profile-menu__title">{{ item.title }}</text>
|
||||
<text class="profile-menu__arrow">›</text>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -40,7 +37,7 @@ interface MenuItem {
|
||||
path?: string
|
||||
isAdmin?: boolean
|
||||
badge?: string
|
||||
action?: 'clear' | 'about'
|
||||
action?: 'clear'
|
||||
requireAuth?: boolean
|
||||
}
|
||||
|
||||
@@ -49,11 +46,11 @@ const props = defineProps<{
|
||||
requireAuth?: boolean
|
||||
activeMembershipCount?: number
|
||||
upcomingBookingCount?: number
|
||||
inviteShareEligible?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'clear-cache'): void
|
||||
(e: 'about'): void
|
||||
(e: 'require-login'): void
|
||||
}>()
|
||||
|
||||
@@ -82,6 +79,25 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
badge: bookingBadge,
|
||||
requireAuth: true,
|
||||
},
|
||||
...(props.isAdmin
|
||||
? [{
|
||||
key: 'teaching-schedule',
|
||||
type: 'item' as const,
|
||||
title: '我的课表',
|
||||
path: '/pages/profile/teaching-schedule',
|
||||
requireAuth: true,
|
||||
}]
|
||||
: []),
|
||||
// 临时隐藏邀请好友入口,后续恢复时直接取消这段注释即可。
|
||||
// ...(props.inviteShareEligible
|
||||
// ? [{
|
||||
// key: 'invite',
|
||||
// type: 'item' as const,
|
||||
// title: '邀请好友',
|
||||
// path: '/pages/profile/invite',
|
||||
// requireAuth: true,
|
||||
// }]
|
||||
// : []),
|
||||
{
|
||||
key: 'info',
|
||||
type: 'item',
|
||||
@@ -99,12 +115,6 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
title: '清除缓存',
|
||||
action: 'clear',
|
||||
},
|
||||
{
|
||||
key: 'about',
|
||||
type: 'item',
|
||||
title: '关于我们',
|
||||
action: 'about',
|
||||
},
|
||||
]
|
||||
|
||||
if (props.isAdmin) {
|
||||
@@ -122,6 +132,9 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
return items
|
||||
})
|
||||
|
||||
const primaryItems = computed(() => menuItems.value.filter(item => item.key === 'membership' || item.key === 'bookings'))
|
||||
const secondaryItems = computed(() => menuItems.value.filter(item => item.key !== 'membership' && item.key !== 'bookings'))
|
||||
|
||||
function handleTap(item: MenuItem) {
|
||||
if (item.requireAuth && !props.requireAuth) {
|
||||
emit('require-login')
|
||||
@@ -129,8 +142,6 @@ function handleTap(item: MenuItem) {
|
||||
}
|
||||
if (item.action === 'clear') {
|
||||
emit('clear-cache')
|
||||
} else if (item.action === 'about') {
|
||||
emit('about')
|
||||
} else if (item.path) {
|
||||
uni.navigateTo({ url: item.path })
|
||||
}
|
||||
@@ -139,243 +150,20 @@ function handleTap(item: MenuItem) {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.profile-menu {
|
||||
background: $bg-card;
|
||||
border-radius: $radius-lg;
|
||||
margin: $spacing-lg $spacing-lg 0;
|
||||
overflow: hidden;
|
||||
|
||||
&__separator {
|
||||
height: $spacing-sm;
|
||||
background: $bg-page;
|
||||
}
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: $spacing-md $spacing-lg;
|
||||
border-bottom: 1rpx solid $border-color;
|
||||
background: $bg-card;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&--hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
&--admin {
|
||||
background: rgba($accent-color, 0.04);
|
||||
}
|
||||
}
|
||||
|
||||
&__icon-wrap {
|
||||
width: 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;
|
||||
line-height: 1;
|
||||
font-weight: 600;
|
||||
color: #496578;
|
||||
background: linear-gradient(135deg, rgba(239, 247, 251, 0.98), rgba(218, 234, 243, 0.96));
|
||||
border-radius: 999rpx;
|
||||
padding: 9rpx 18rpx;
|
||||
margin-right: $spacing-sm;
|
||||
border: 1rpx solid rgba(123, 165, 190, 0.18);
|
||||
box-shadow:
|
||||
inset 0 1rpx 0 rgba(255, 255, 255, 0.92),
|
||||
0 6rpx 16rpx rgba(123, 165, 190, 0.16);
|
||||
}
|
||||
|
||||
&__arrow {
|
||||
font-size: 36rpx;
|
||||
color: $text-hint;
|
||||
line-height: 1;
|
||||
transform: scaleX(0.6);
|
||||
transform-origin: center;
|
||||
}
|
||||
&__shortcuts { display: flex; gap: 20rpx; margin: 24rpx 32rpx 0; }
|
||||
&__shortcut { flex: 1; min-width: 0; padding: 24rpx; box-sizing: border-box; border-radius: 26rpx; background: #fff; border: 1rpx solid #eee8e3; }
|
||||
&__shortcut-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16rpx; }
|
||||
&__shortcut-title { display: block; font-size: 27rpx; font-weight: 500; color: #514943; }
|
||||
&__shortcut-note { display: block; font-size: 22rpx; color: #8b817b; margin-top: 8rpx; }
|
||||
&__icon { width: 36rpx; height: 30rpx; position: relative; box-sizing: border-box; border: 2rpx solid #9b8878; border-radius: 6rpx; }
|
||||
&__icon--membership::after { content: ''; position: absolute; top: 8rpx; left: 0; right: 0; border-top: 2rpx solid #9b8878; }
|
||||
&__icon--bookings { border-color: #829a8b; border-top-width: 7rpx; &::after { content: ''; position: absolute; left: 8rpx; top: 8rpx; width: 12rpx; border-top: 2rpx solid #829a8b; } }
|
||||
&__links { margin: 24rpx 32rpx 0; border-radius: 26rpx; background: #fff; overflow: hidden; }
|
||||
&__item { display: flex; align-items: center; gap: 20rpx; min-height: 96rpx; padding: 0 28rpx; box-sizing: border-box; border-bottom: 1rpx solid #f2ede8; &:last-child { border-bottom: none; } }
|
||||
&__item--hover { background: #f4f1ec; }
|
||||
&__item--admin { background: #eff3ed; .profile-menu__title { color: #617d73; } }
|
||||
&__title { flex: 1; font-size: 27rpx; font-weight: 400; color: #6f655e; }
|
||||
&__arrow { font-size: 30rpx; color: #b2a79e; line-height: 1; }
|
||||
&__separator { height: 12rpx; background: #fbf9f6; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
<view class="quick-entry">
|
||||
<!-- ① Not logged in -->
|
||||
<view v-if="!userStore.loggedIn" class="entry-pill pill-login" @tap="handleLogin">
|
||||
<view class="pill-dot dot-login" />
|
||||
<text class="pill-label">欢迎来到工作室</text>
|
||||
<view class="pill-action action-login">
|
||||
<text class="pill-action-text">微信登录</text>
|
||||
@@ -15,7 +14,7 @@
|
||||
class="entry-pill pill-trial"
|
||||
@tap="handleTrialEntry"
|
||||
>
|
||||
<view class="pill-tag tag-trial">体验</view>
|
||||
|
||||
<text class="pill-label">首次体验专属课程</text>
|
||||
<view class="pill-action action-trial">
|
||||
<text class="pill-action-text">预约体验课</text>
|
||||
@@ -25,16 +24,15 @@
|
||||
<!-- ③ Has valid active card -->
|
||||
<template v-else-if="userStore.hasValidMembership">
|
||||
<view class="entry-pill pill-active" @tap="handleBooking">
|
||||
<view class="pill-dot dot-active" />
|
||||
<text class="pill-label pill-label-active">{{ activeMembershipLabel }}</text>
|
||||
<text class="pill-label pill-label-active">{{ activeMembershipLabel }}</text>
|
||||
<view class="pill-action action-book">
|
||||
<text class="pill-action-text">约课</text>
|
||||
<text class="pill-action-text">预约课程</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Running low: thin accent strip -->
|
||||
<view v-if="isRunningLow" class="renew-strip" @tap="scrollToCardShop">
|
||||
<text class="renew-strip-text">仅剩 {{ lowestRemainingTimes }} 次 · 续卡保持节奏</text>
|
||||
<view v-if="renewStripText" class="renew-strip" @tap="handleRenew">
|
||||
<text class="renew-strip-text">{{ renewStripText }}</text>
|
||||
<text class="renew-strip-arrow">›</text>
|
||||
</view>
|
||||
</template>
|
||||
@@ -43,10 +41,9 @@
|
||||
<view
|
||||
v-else
|
||||
class="entry-pill pill-expired"
|
||||
@tap="scrollToCardShop"
|
||||
@tap="handleRenew"
|
||||
>
|
||||
<view class="pill-dot dot-expired" />
|
||||
<text class="pill-label">会员卡已到期</text>
|
||||
<text class="pill-label">暂无可用会员卡</text>
|
||||
<view class="pill-action action-renew">
|
||||
<text class="pill-action-text">续卡</text>
|
||||
</view>
|
||||
@@ -57,7 +54,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useUserStore } from '../stores/user'
|
||||
import { CardTypeCategory } from '@mp-pilates/shared'
|
||||
import { getErrorMessage } from '../utils/auth'
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -91,12 +87,21 @@ function scrollToCardShop() {
|
||||
emit('scroll-to-card-shop')
|
||||
}
|
||||
|
||||
function handleRenew() {
|
||||
const cardTypeId = userStore.renewalHint?.cardTypeId
|
||||
if (cardTypeId) {
|
||||
uni.navigateTo({ url: `/pages/card/detail?id=${cardTypeId}` })
|
||||
return
|
||||
}
|
||||
scrollToCardShop()
|
||||
}
|
||||
|
||||
const activeMembershipLabel = computed(() => {
|
||||
const active = userStore.activeMemberships
|
||||
if (!active.length) return ''
|
||||
const m = active[0]
|
||||
const cardName = m.cardType.name
|
||||
if (m.cardType.type === CardTypeCategory.TIMES && m.remainingTimes !== null) {
|
||||
if (m.remainingTimes !== null) {
|
||||
return `${cardName} · 剩余 ${m.remainingTimes} 次`
|
||||
}
|
||||
const expire = new Date(m.expireDate)
|
||||
@@ -105,182 +110,32 @@ const activeMembershipLabel = computed(() => {
|
||||
return `${cardName} · 剩余 ${daysLeft} 天`
|
||||
})
|
||||
|
||||
const isRunningLow = computed(() => {
|
||||
return userStore.activeMemberships.some(
|
||||
(m) =>
|
||||
m.cardType.type === CardTypeCategory.TIMES &&
|
||||
m.remainingTimes !== null &&
|
||||
m.remainingTimes <= 2,
|
||||
)
|
||||
})
|
||||
|
||||
const lowestRemainingTimes = computed(() => {
|
||||
const timesCards = userStore.activeMemberships.filter(
|
||||
(m) =>
|
||||
m.cardType.type === CardTypeCategory.TIMES &&
|
||||
m.remainingTimes !== null &&
|
||||
m.remainingTimes <= 2,
|
||||
)
|
||||
if (!timesCards.length) return 0
|
||||
return Math.min(...timesCards.map((m) => m.remainingTimes as number))
|
||||
const renewStripText = computed(() => {
|
||||
const hint = userStore.renewalHint
|
||||
if (!hint || !userStore.hasValidMembership) return ''
|
||||
if (hint.kind === 'times_low') {
|
||||
return `仅剩 ${hint.remainingTimes ?? 0} 次 · 续卡保持节奏`
|
||||
}
|
||||
if (hint.kind === 'days_low') {
|
||||
return `还剩 ${hint.daysLeft ?? 0} 天到期 · 续卡不中断`
|
||||
}
|
||||
if (hint.kind === 'trial_low') {
|
||||
return '体验课将尽 · 选购会员卡'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.quick-entry {
|
||||
padding: 20rpx 24rpx 0;
|
||||
}
|
||||
|
||||
/* ── Pill base ── */
|
||||
.entry-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 80rpx;
|
||||
border-radius: 40rpx;
|
||||
padding: 0 8rpx 0 24rpx;
|
||||
gap: 16rpx;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
/* ── Pill variants ── */
|
||||
.pill-login {
|
||||
background: #1a1a2e;
|
||||
}
|
||||
|
||||
.pill-trial {
|
||||
background: linear-gradient(135deg, #2d2d5e 0%, #4a3f7a 100%);
|
||||
}
|
||||
|
||||
.pill-active {
|
||||
background: #ffffff;
|
||||
border: 1rpx solid rgba(0, 0, 0, 0.06);
|
||||
box-shadow: 0 2rpx 16rpx rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.pill-expired {
|
||||
background: #f5f5f5;
|
||||
border: 1rpx solid rgba(0, 0, 0, 0.04);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* ── Status dot ── */
|
||||
.pill-dot {
|
||||
width: 14rpx;
|
||||
height: 14rpx;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dot-login {
|
||||
background: $primary-color;
|
||||
box-shadow: 0 0 8rpx rgba($primary-color, 0.6);
|
||||
}
|
||||
|
||||
.dot-active {
|
||||
background: #34c759;
|
||||
box-shadow: 0 0 8rpx rgba(52, 199, 89, 0.5);
|
||||
}
|
||||
|
||||
.dot-expired {
|
||||
background: #aaa;
|
||||
}
|
||||
|
||||
/* ── Tag (trial only) ── */
|
||||
.pill-tag {
|
||||
font-size: 20rpx;
|
||||
font-weight: 700;
|
||||
padding: 4rpx 14rpx;
|
||||
border-radius: 20rpx;
|
||||
flex-shrink: 0;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
|
||||
.tag-trial {
|
||||
background: rgba(255, 215, 0, 0.25);
|
||||
color: #ffd700;
|
||||
}
|
||||
|
||||
/* ── Label text ── */
|
||||
.pill-label {
|
||||
flex: 1;
|
||||
font-size: 26rpx;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.pill-label-active {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.pill-expired .pill-label {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
/* ── Action button ── */
|
||||
.pill-action {
|
||||
flex-shrink: 0;
|
||||
height: 60rpx;
|
||||
padding: 0 28rpx;
|
||||
border-radius: 30rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pill-action-text {
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.action-login {
|
||||
background: $primary-color;
|
||||
.pill-action-text { color: #1a1a2e; }
|
||||
}
|
||||
|
||||
.action-trial {
|
||||
background: rgba(255, 215, 0, 0.2);
|
||||
.pill-action-text { color: #ffd700; }
|
||||
}
|
||||
|
||||
.action-book {
|
||||
background: #1a1a2e;
|
||||
.pill-action-text { color: #fff; }
|
||||
}
|
||||
|
||||
.action-renew {
|
||||
background: #e0e0e0;
|
||||
.pill-action-text { color: #555; }
|
||||
}
|
||||
|
||||
/* ── Renew strip (running low) ── */
|
||||
.renew-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8rpx;
|
||||
margin-top: 12rpx;
|
||||
padding: 14rpx 24rpx;
|
||||
background: linear-gradient(135deg, #FF6B35, #FF8E53);
|
||||
border-radius: 24rpx;
|
||||
}
|
||||
|
||||
.renew-strip-text {
|
||||
font-size: 22rpx;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
letter-spacing: 0.5rpx;
|
||||
}
|
||||
|
||||
.renew-strip-arrow {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
line-height: 1;
|
||||
}
|
||||
.quick-entry { margin: 24rpx 32rpx 0; }
|
||||
.entry-pill { display: flex; align-items: center; min-height: 120rpx; box-sizing: border-box; padding: 24rpx; gap: 20rpx; border-radius: 28rpx; background: #f1e8e1; }
|
||||
.pill-active { background: #edf2ec; }
|
||||
.pill-expired { background: #f0ece7; }
|
||||
.pill-label { flex: 1; min-width: 0; font-size: 26rpx; line-height: 1.6; color: #6f5c50; overflow-wrap: anywhere; }
|
||||
.pill-label-active { color: #526b5d; }
|
||||
.pill-action { flex-shrink: 0; min-height: 68rpx; padding: 0 24rpx; border-radius: 999rpx; display: flex; align-items: center; justify-content: center; background: #6b8276; }
|
||||
.pill-action-text { font-size: 24rpx; font-weight: 400; color: #fff; white-space: nowrap; }
|
||||
.renew-strip { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; padding: 18rpx 12rpx 0; }
|
||||
.renew-strip-text { font-size: 22rpx; color: #8b817b; line-height: 1.5; }
|
||||
.renew-strip-arrow { font-size: 28rpx; color: #8b817b; }
|
||||
</style>
|
||||
|
||||
288
packages/app/src/components/SessionRow.vue
Normal file
288
packages/app/src/components/SessionRow.vue
Normal 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>
|
||||
@@ -1,94 +1,29 @@
|
||||
<template>
|
||||
<view class="slot-card-wrapper" :class="[`status-${statusClass}`]" @tap="emit('cardTap', timeSlot)">
|
||||
<!-- Ticket background image -->
|
||||
<image
|
||||
class="ticket-bg"
|
||||
src="/static/courseBg.png"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
|
||||
<!-- Card content overlay -->
|
||||
<view class="ticket-content">
|
||||
<!-- ── Top section: Time row (like flight ticket) ── -->
|
||||
<view class="ticket-top">
|
||||
<!-- Left: Start time -->
|
||||
<view class="time-block">
|
||||
<text class="time-main">{{ startTimeDisplay }}</text>
|
||||
<text class="time-label">{{ timeSlot.date }}</text>
|
||||
</view>
|
||||
|
||||
<!-- Center: Duration + icon -->
|
||||
<view class="duration-block">
|
||||
<view class="duration-line">
|
||||
<view class="line-dot" />
|
||||
<view class="line-dash" />
|
||||
<view class="duration-icon">
|
||||
<text class="icon-text">⏱</text>
|
||||
</view>
|
||||
<view class="line-dash" />
|
||||
<view class="line-dot" />
|
||||
</view>
|
||||
<text class="duration-text">{{ durationMin }}分钟</text>
|
||||
</view>
|
||||
|
||||
<!-- Right: End time -->
|
||||
<view class="time-block time-block--right">
|
||||
<text class="time-main">{{ endTimeDisplay }}</text>
|
||||
<view class="capacity-tag" :class="capacityClass">
|
||||
<text class="capacity-text">{{ capacityLabel }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="slot-card" :class="`slot-card--${statusClass}`" @tap="emit('cardTap', timeSlot)">
|
||||
<view class="slot-heading">
|
||||
<view class="slot-time">
|
||||
<text class="time-start">{{ startTimeDisplay }}</text>
|
||||
<text class="time-end">— {{ endTimeDisplay }}</text>
|
||||
</view>
|
||||
|
||||
<!-- ── Dashed tear-off line ── -->
|
||||
<view class="tear-line" />
|
||||
|
||||
<!-- ── Bottom section: Course name + Action ── -->
|
||||
<view class="ticket-bottom">
|
||||
<view class="course-info">
|
||||
<text class="course-name">普拉提私教</text>
|
||||
</view>
|
||||
|
||||
<!-- Action area -->
|
||||
<view class="action-area">
|
||||
<!-- Expired -->
|
||||
<template v-if="isPast && !timeSlot.isBookedByMe">
|
||||
<view class="action-badge badge-expired">
|
||||
<text>已过期</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- OPEN + not booked -->
|
||||
<template v-else-if="timeSlot.status === TimeSlotStatus.OPEN && !timeSlot.isBookedByMe">
|
||||
<view class="action-btn btn-book" @tap.stop="emit('book', timeSlot)">
|
||||
<text>预约</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- OPEN + booked by me -->
|
||||
<template v-else-if="timeSlot.status === TimeSlotStatus.OPEN && timeSlot.isBookedByMe">
|
||||
<view class="action-badge badge-booked">
|
||||
<text>{{ myBookingLabel }}</text>
|
||||
</view>
|
||||
<view class="cancel-link" @tap.stop="emit('cancel', timeSlot)">
|
||||
<text>取消</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- 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>
|
||||
<text class="slot-duration">{{ durationMin }} 分钟</text>
|
||||
</view>
|
||||
<view class="slot-body">
|
||||
<view class="course-info">
|
||||
<text class="course-name">普拉提私教</text>
|
||||
<text class="slot-status">{{ capacityLabel }}</text>
|
||||
</view>
|
||||
<view class="action-area">
|
||||
<template v-if="timeSlot.isBookedByMe">
|
||||
<text class="booked-label">已预约</text>
|
||||
<view v-if="timeSlot.status === TimeSlotStatus.OPEN" class="cancel-link" @tap.stop="emit('cancel', timeSlot)">
|
||||
<text>取消预约</text>
|
||||
</view>
|
||||
</template>
|
||||
<button v-else-if="!isPast && timeSlot.status === TimeSlotStatus.OPEN && timeSlot.bookedCount < timeSlot.capacity"
|
||||
class="book-button" hover-class="book-button--pressed" @tap.stop="emit('book', timeSlot)">
|
||||
预约
|
||||
</button>
|
||||
<text v-else class="unavailable-label">{{ isPast ? '已结束' : timeSlot.status === TimeSlotStatus.CLOSED ? '未开放' : '已约满' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -122,24 +57,17 @@ const durationMin = computed(() => {
|
||||
|
||||
const myBookingLabel = computed(() => (
|
||||
props.timeSlot.myBookingStatus === BookingStatus.PENDING_CONFIRMATION
|
||||
? '已预约待确认'
|
||||
? '待老师确认'
|
||||
: '已预约'
|
||||
))
|
||||
|
||||
const capacityLabel = computed(() => {
|
||||
const { bookedCount, capacity, status } = props.timeSlot
|
||||
if (status === TimeSlotStatus.CLOSED) return '已关闭'
|
||||
if (status === TimeSlotStatus.FULL) return '已约满'
|
||||
const remaining = capacity - bookedCount
|
||||
return `剩余${remaining}位`
|
||||
})
|
||||
|
||||
const capacityClass = computed(() => {
|
||||
const { bookedCount, capacity, status } = props.timeSlot
|
||||
if (status === TimeSlotStatus.CLOSED) return 'cap-closed'
|
||||
if (status === TimeSlotStatus.FULL) return 'cap-full'
|
||||
if (bookedCount >= capacity * 0.8) return 'cap-almost'
|
||||
return 'cap-open'
|
||||
const { bookedCount, capacity, status, isBookedByMe } = props.timeSlot
|
||||
if (isBookedByMe) return myBookingLabel.value
|
||||
if (isPast.value) return '已结束'
|
||||
if (status === TimeSlotStatus.CLOSED) return '暂未开放'
|
||||
if (status === TimeSlotStatus.FULL || bookedCount >= capacity) return '名额已满'
|
||||
return `还可约 ${Math.max(0, capacity - bookedCount)} 位`
|
||||
})
|
||||
|
||||
const statusClass = computed(() => {
|
||||
@@ -154,275 +82,39 @@ const isPast = computed(() => isSlotPast(props.timeSlot.date, props.timeSlot.sta
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* ─── Wrapper ─── */
|
||||
.slot-card-wrapper {
|
||||
position: relative;
|
||||
margin: 0 24rpx 20rpx;
|
||||
min-height: 220rpx;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Status-based opacity */
|
||||
&.status-expired {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
&.status-full,
|
||||
&.status-closed {
|
||||
opacity: 0.75;
|
||||
}
|
||||
.slot-card {
|
||||
margin: 0 32rpx 20rpx;
|
||||
padding: 28rpx;
|
||||
border: 1rpx solid #eee8e3;
|
||||
border-radius: 28rpx;
|
||||
background: #fff;
|
||||
color: #514943;
|
||||
}
|
||||
|
||||
/* ─── Ticket background image ─── */
|
||||
.ticket-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
.slot-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 16rpx; }
|
||||
.slot-time { display: flex; align-items: baseline; gap: 12rpx; font-variant-numeric: tabular-nums; }
|
||||
.time-start { font-size: 40rpx; font-weight: 500; line-height: 1.2; }
|
||||
.time-end { font-size: 28rpx; color: #8b817b; }
|
||||
.slot-duration { flex-shrink: 0; font-size: 22rpx; color: #8b817b; }
|
||||
.slot-body { display: flex; align-items: center; justify-content: space-between; gap: 20rpx; margin-top: 24rpx; }
|
||||
.course-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
|
||||
.course-name { font-size: 26rpx; font-weight: 400; }
|
||||
.slot-status { font-size: 22rpx; color: #617d73; }
|
||||
.action-area { display: flex; flex-direction: column; align-items: flex-end; gap: 2rpx; flex-shrink: 0; }
|
||||
.book-button {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
min-width: 136rpx; height: 72rpx; margin: 0; padding: 0 30rpx;
|
||||
border: none; border-radius: 999rpx; background: #6b8276;
|
||||
font-size: 26rpx; font-weight: 400; line-height: 1; color: #fff;
|
||||
&::after { border: none; }
|
||||
&--pressed { background: #597264; }
|
||||
}
|
||||
|
||||
/* ─── Content overlay ─── */
|
||||
.ticket-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 28rpx 40rpx 24rpx;
|
||||
}
|
||||
|
||||
/* ═══ Top section: Time row ═══ */
|
||||
.ticket-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.time-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
min-width: 100rpx;
|
||||
|
||||
&--right {
|
||||
align-items: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.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);
|
||||
.booked-label { font-size: 24rpx; color: #617d73; }
|
||||
.cancel-link { padding: 12rpx 0 8rpx 20rpx; font-size: 21rpx; color: #8b817b; }
|
||||
.unavailable-label { padding: 14rpx 24rpx; border-radius: 999rpx; background: #f3f0ed; color: #8b817b; font-size: 23rpx; }
|
||||
.slot-card--booked { background: #f2f5f0; border-color: #e2e9df; }
|
||||
.slot-card--expired, .slot-card--closed, .slot-card--full {
|
||||
background: #f7f5f2;
|
||||
.time-start, .course-name { color: #8b817b; }
|
||||
.slot-status { color: #948a82; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<view class="studio-info">
|
||||
<text class="section-title">来工作室坐坐</text>
|
||||
<!-- Address + Chat row -->
|
||||
<view class="location-row">
|
||||
<view class="location-left" @tap="handleAddressTap">
|
||||
<view class="location-icon" />
|
||||
<view class="location-content">
|
||||
<text class="location-label">场馆地址</text>
|
||||
<text class="location-text">
|
||||
@@ -12,7 +12,7 @@
|
||||
</view>
|
||||
</view>
|
||||
<button class="chat-btn" open-type="contact">
|
||||
<view class="chat-icon" />
|
||||
<text>联系老师</text>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
@tap="previewPhoto(idx)"
|
||||
>
|
||||
<image class="gallery-image" :src="photo" mode="aspectFill" />
|
||||
<view class="gallery-overlay" />
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
@@ -37,22 +36,18 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { StudioConfig } from '@mp-pilates/shared'
|
||||
import {
|
||||
DEFAULT_STUDIO_GALLERY_PHOTOS,
|
||||
type StudioConfig,
|
||||
} from '@mp-pilates/shared'
|
||||
|
||||
const props = defineProps<{
|
||||
studioInfo: StudioConfig | null
|
||||
}>()
|
||||
|
||||
const defaultGalleryPhotos = [
|
||||
'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/images/place_1.jpg',
|
||||
'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/images/place_2.jpg',
|
||||
'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/images/place_3.jpg',
|
||||
'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/images/place_4.jpg',
|
||||
]
|
||||
|
||||
const galleryPhotos = computed(() => {
|
||||
const photos = props.studioInfo?.photos?.filter(Boolean) ?? []
|
||||
return photos.length ? photos : defaultGalleryPhotos
|
||||
return photos.length ? photos : [...DEFAULT_STUDIO_GALLERY_PHOTOS]
|
||||
})
|
||||
|
||||
function previewPhoto(index: number) {
|
||||
@@ -92,179 +87,15 @@ function copyAddress() {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.studio-info {
|
||||
margin: 16rpx 24rpx 0;
|
||||
background: #ffffff;
|
||||
border-radius: 20rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* ── Location row ── */
|
||||
.location-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 28rpx 32rpx 24rpx;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.location-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.location-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.location-label {
|
||||
display: block;
|
||||
font-size: 22rpx;
|
||||
color: #b39a92;
|
||||
letter-spacing: 2rpx;
|
||||
margin-bottom: 6rpx;
|
||||
}
|
||||
|
||||
.location-text {
|
||||
font-size: 26rpx;
|
||||
color: #5f5955;
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* ── Gallery ── */
|
||||
.gallery-block {
|
||||
padding: 6rpx 0 28rpx;
|
||||
}
|
||||
|
||||
.gallery-scroll {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.gallery-track {
|
||||
display: flex;
|
||||
gap: 14rpx;
|
||||
padding: 0 32rpx;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.gallery-item {
|
||||
position: relative;
|
||||
width: 192rpx;
|
||||
height: 108rpx;
|
||||
border-radius: 14rpx;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background: linear-gradient(135deg, #eadfd8 0%, #d5c0b4 100%);
|
||||
box-shadow: 0 8rpx 18rpx rgba(124, 95, 82, 0.1);
|
||||
}
|
||||
|
||||
.gallery-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.gallery-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.1) 0%, rgba(38, 28, 24, 0.2) 100%),
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.08) 0%, transparent 48%);
|
||||
}
|
||||
|
||||
/* ── Icons ── */
|
||||
.location-icon {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba($brand-color, 0.06);
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
|
||||
// 定位图标 — 圆头 + 尖尾
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 14rpx;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 18rpx;
|
||||
height: 18rpx;
|
||||
border: 2.5rpx solid $brand-color;
|
||||
border-radius: 50% 50% 50% 0;
|
||||
transform: translateX(-50%) rotate(-45deg);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
// 中心白点
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 21rpx;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 6rpx;
|
||||
height: 6rpx;
|
||||
background: $brand-color;
|
||||
border-radius: 50%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-btn {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 50%;
|
||||
background: 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;
|
||||
}
|
||||
}
|
||||
.studio-info { margin: 36rpx 32rpx 0; }
|
||||
.section-title { display: block; margin-bottom: 20rpx; font-size: 30rpx; font-weight: 500; color: #514943; }
|
||||
.location-row { display: flex; align-items: center; justify-content: space-between; gap: 20rpx; margin-bottom: 20rpx; }
|
||||
.location-left, .location-content { flex: 1; min-width: 0; }
|
||||
.location-label { display: block; font-size: 21rpx; color: #8b817b; margin-bottom: 6rpx; }
|
||||
.location-text { font-size: 24rpx; color: #6f655e; line-height: 1.7; overflow-wrap: anywhere; }
|
||||
.chat-btn { flex-shrink: 0; margin: 0; padding: 0 20rpx; line-height: 64rpx; font-size: 23rpx; border: none; border-radius: 999rpx; background: #f0eae4; color: #78675c; &::after { border: none; } }
|
||||
.gallery-scroll { width: 100%; }
|
||||
.gallery-track { display: inline-flex; gap: 16rpx; }
|
||||
.gallery-item { width: 264rpx; height: 180rpx; border-radius: 20rpx; overflow: hidden; flex-shrink: 0; background: #e9e1d8; }
|
||||
.gallery-image { width: 100%; height: 100%; }
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<view class="time-period-filter">
|
||||
<view class="time-period-filter" :class="`time-period-filter--${variant}`">
|
||||
<view
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key ?? 'all'"
|
||||
class="tab-item"
|
||||
:class="{ active: modelValue === tab.key }"
|
||||
:class="[`tab-item--${variant}`, { active: modelValue === tab.key }]"
|
||||
@tap="handleChange(tab.key)"
|
||||
>
|
||||
<text class="tab-label">{{ tab.label }}</text>
|
||||
@@ -25,14 +25,17 @@ interface Tab {
|
||||
|
||||
interface Props {
|
||||
modelValue: PeriodKey
|
||||
variant?: 'default' | 'booking' | 'soft'
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
change: [period: PeriodKey]
|
||||
'update:modelValue': [period: PeriodKey]
|
||||
}>()
|
||||
|
||||
const variant = computed(() => props.variant ?? 'default')
|
||||
|
||||
const tabs = computed<Tab[]>(() => [
|
||||
{ key: null, label: '全部' },
|
||||
...Object.entries(TIME_PERIODS).map(([key, val]) => ({
|
||||
@@ -55,6 +58,11 @@ function handleChange(key: PeriodKey) {
|
||||
padding: 0 24rpx;
|
||||
border-bottom: 1rpx solid $primary-border;
|
||||
|
||||
&.time-period-filter--booking {
|
||||
background: rgba(252, 250, 248, 0.96);
|
||||
border-bottom-color: rgba(192, 154, 137, 0.12);
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -87,6 +95,40 @@ function handleChange(key: PeriodKey) {
|
||||
border-radius: 2rpx;
|
||||
}
|
||||
}
|
||||
|
||||
&.tab-item--booking {
|
||||
.tab-label {
|
||||
color: #9d8b83;
|
||||
}
|
||||
|
||||
&.active {
|
||||
.tab-label {
|
||||
color: #8f6759;
|
||||
}
|
||||
|
||||
&::after {
|
||||
width: 48rpx;
|
||||
height: 5rpx;
|
||||
background: linear-gradient(90deg, #c8a899, #a87d6c);
|
||||
border-radius: 999rpx;
|
||||
box-shadow: 0 4rpx 10rpx rgba(168, 125, 108, 0.18);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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>
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
<view class="details-col">
|
||||
<text class="card-name">{{ booking.membership.cardType.name }}</text>
|
||||
<view class="time-row">
|
||||
<text class="time-icon">🕐</text>
|
||||
<text class="time-text">
|
||||
{{ formatTime(booking.timeSlot.startTime) }} – {{ formatTime(booking.timeSlot.endTime) }}
|
||||
</text>
|
||||
@@ -108,132 +107,25 @@ function goToBookingDetail(id: string) {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.upcoming-section {
|
||||
margin: 24rpx 24rpx 0;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.section-more {
|
||||
font-size: 26rpx;
|
||||
color: $primary-dark;
|
||||
}
|
||||
|
||||
.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--pending { background: #f39c12; }
|
||||
.dot--confirmed { background: #27ae60; }
|
||||
.dot--completed { background: #3498db; }
|
||||
.dot--cancelled { background: #e74c3c; }
|
||||
.dot--default { background: #999; }
|
||||
|
||||
.status-text {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.text--pending { color: #f39c12; }
|
||||
.text--confirmed { color: #27ae60; }
|
||||
.text--completed { color: #3498db; }
|
||||
.text--cancelled { color: #e74c3c; }
|
||||
|
||||
.booking-arrow {
|
||||
font-size: 36rpx;
|
||||
color: #ccc;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.upcoming-section { margin: 36rpx 32rpx 0; }
|
||||
.section-header { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; margin-bottom: 20rpx; }
|
||||
.section-title { font-size: 30rpx; font-weight: 500; color: #514943; }
|
||||
.section-more { font-size: 23rpx; color: #8b817b; padding: 8rpx 0; }
|
||||
.booking-card { display: flex; align-items: center; gap: 24rpx; margin-bottom: 16rpx; padding: 26rpx; border-radius: 28rpx; background: #fff; border: 1rpx solid #eee8e3; }
|
||||
.date-col { display: flex; flex-direction: column; align-items: center; gap: 4rpx; width: 72rpx; flex-shrink: 0; }
|
||||
.date-day { font-size: 44rpx; font-weight: 400; line-height: 1.1; color: #617d73; }
|
||||
.date-month, .date-weekday { font-size: 20rpx; color: #8b817b; }
|
||||
.booking-divider { width: 1rpx; height: 80rpx; background: #eee8e3; flex-shrink: 0; }
|
||||
.details-col { flex: 1; min-width: 0; }
|
||||
.card-name { display: block; font-size: 26rpx; color: #514943; font-weight: 400; line-height: 1.5; margin-bottom: 8rpx; overflow-wrap: anywhere; }
|
||||
.time-row { margin-bottom: 8rpx; }
|
||||
.time-text { font-size: 24rpx; color: #6f655e; font-variant-numeric: tabular-nums; }
|
||||
.status-row { display: flex; align-items: center; gap: 8rpx; }
|
||||
.status-dot { width: 8rpx; height: 8rpx; border-radius: 50%; background: #a5b8ab; }
|
||||
.status-text { font-size: 21rpx; color: #617d73; }
|
||||
.dot--pending { background: #b8a18b; }
|
||||
.text--pending { color: #957c65; }
|
||||
.dot--cancelled { background: #b69589; }
|
||||
.text--cancelled { color: #9c7a6e; }
|
||||
.booking-arrow { font-size: 32rpx; color: #b2a79e; flex-shrink: 0; }
|
||||
</style>
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
<template>
|
||||
<view class="user-card">
|
||||
<!-- Header: gradient background, padded to sit below nav bar -->
|
||||
<view class="user-card__header" :style="{ paddingTop: (navBarHeight ?? 0) + 'px' }">
|
||||
<view class="user-card__header">
|
||||
<!-- Not logged in state -->
|
||||
<view v-if="!loggedIn" class="user-card__guest">
|
||||
<view class="user-card__avatar-wrap">
|
||||
<image class="user-card__avatar-img" src="/static/default-avatar.jpg" mode="aspectFill" />
|
||||
</view>
|
||||
<view class="user-card__guest-info">
|
||||
<text class="user-card__guest-title">Hi,欢迎来到普拉提</text>
|
||||
<text class="user-card__guest-sub">登录后查看个人数据</text>
|
||||
<text class="user-card__guest-title">欢迎来到工作室</text>
|
||||
<text class="user-card__guest-sub">登录后查看会员卡与练习记录</text>
|
||||
</view>
|
||||
<button class="user-card__login-btn" :loading="loading" @tap="handleLogin">
|
||||
微信登录
|
||||
@@ -17,7 +16,7 @@
|
||||
</view>
|
||||
|
||||
<!-- Logged in + profile loaded -->
|
||||
<view v-else-if="loggedIn && hasProfile" class="user-card__user">
|
||||
<view v-else-if="loggedIn && hasProfile" class="user-card__user" @tap="emit('edit')">
|
||||
<view class="user-card__avatar-wrap">
|
||||
<image
|
||||
class="user-card__avatar-img"
|
||||
@@ -29,15 +28,11 @@
|
||||
<view class="user-card__info">
|
||||
<view class="user-card__name-row">
|
||||
<text class="user-card__nickname">{{ user!.nickname }}</text>
|
||||
<view v-if="hasMembership" class="user-card__member-badge">
|
||||
<view class="user-card__member-icon">
|
||||
<text class="user-card__member-letter">C</text>
|
||||
</view>
|
||||
<text class="user-card__member-label">CLUB</text>
|
||||
</view>
|
||||
<text v-if="hasMembership" class="user-card__member-label">会员</text>
|
||||
</view>
|
||||
<text v-if="maskedPhone" class="user-card__phone">{{ maskedPhone }}</text>
|
||||
<text class="user-card__phone">{{ maskedPhone || '完善个人资料' }}</text>
|
||||
</view>
|
||||
<text class="user-card__edit">资料 ›</text>
|
||||
</view>
|
||||
|
||||
<!-- Logged in but profile still loading -->
|
||||
@@ -57,18 +52,18 @@
|
||||
<!-- Stats row: shown only when profile is loaded -->
|
||||
<view v-if="loggedIn && hasProfile" class="user-card__stats">
|
||||
<view class="user-card__stat-item">
|
||||
<text class="user-card__stat-value">{{ stats?.totalBookings ?? 0 }}</text>
|
||||
<text class="user-card__stat-label">总训练(次)</text>
|
||||
<text class="user-card__stat-value">{{ stats?.totalBookings ?? '—' }}</text>
|
||||
<text class="user-card__stat-label">累计上课 · 节</text>
|
||||
</view>
|
||||
<view class="user-card__stat-divider" />
|
||||
<view class="user-card__stat-item">
|
||||
<text class="user-card__stat-value">{{ stats?.monthBookings ?? 0 }}</text>
|
||||
<text class="user-card__stat-label">本月(次)</text>
|
||||
<text class="user-card__stat-value">{{ stats?.monthBookings ?? '—' }}</text>
|
||||
<text class="user-card__stat-label">本月上课 · 节</text>
|
||||
</view>
|
||||
<view class="user-card__stat-divider" />
|
||||
<view class="user-card__stat-item">
|
||||
<text class="user-card__stat-value">{{ remainingSessions }}</text>
|
||||
<text class="user-card__stat-label">剩余课时(节)</text>
|
||||
<text class="user-card__stat-label">剩余课时 · 节</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -77,7 +72,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { UserProfileResponse, UserStatsResponse, MembershipWithCardType } from '@mp-pilates/shared'
|
||||
import { CardTypeCategory, MembershipStatus } from '@mp-pilates/shared'
|
||||
import { MembershipStatus } from '@mp-pilates/shared'
|
||||
|
||||
const props = defineProps<{
|
||||
loggedIn: boolean
|
||||
@@ -86,12 +81,11 @@ const props = defineProps<{
|
||||
stats: UserStatsResponse | null
|
||||
memberships?: readonly MembershipWithCardType[]
|
||||
loading?: boolean
|
||||
/** Height of the custom nav bar in px, so header content starts below it */
|
||||
navBarHeight?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'login'): void
|
||||
(e: 'edit'): void
|
||||
}>()
|
||||
|
||||
const avatarFailed = ref(false)
|
||||
@@ -133,10 +127,10 @@ function toSafeCount(value: number | null | undefined): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : 0
|
||||
}
|
||||
|
||||
// Sum remaining sessions from all active time-based memberships
|
||||
// Sum remaining sessions from all active count-limited memberships.
|
||||
const remainingSessions = computed(() =>
|
||||
activeMemberships.value
|
||||
.filter((m) => m.cardType.type === CardTypeCategory.TIMES)
|
||||
.filter((m) => m.remainingTimes !== null)
|
||||
.reduce((sum, m) => sum + toSafeCount(m.remainingTimes), 0),
|
||||
)
|
||||
|
||||
@@ -151,221 +145,28 @@ function handleLogin() {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.user-card {
|
||||
background: linear-gradient(160deg, #E1F4FA 0%, $primary-color 50%, $primary-dark 100%);
|
||||
border-radius: 0 0 40rpx 40rpx;
|
||||
overflow: hidden;
|
||||
|
||||
&__header {
|
||||
padding: $spacing-lg $spacing-lg $spacing-lg;
|
||||
}
|
||||
|
||||
// ── Guest state ──
|
||||
&__guest {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
&__guest-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
|
||||
&__guest-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
&__guest-sub {
|
||||
font-size: 24rpx;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
&__login-btn {
|
||||
flex-shrink: 0;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: #ffffff;
|
||||
font-size: 26rpx;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
border-radius: $radius-lg;
|
||||
padding: 0 $spacing-md;
|
||||
height: 64rpx;
|
||||
line-height: 64rpx;
|
||||
min-width: 160rpx;
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Logged-in user ──
|
||||
&__user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
&__avatar-wrap {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
}
|
||||
|
||||
&__avatar-img {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 50%;
|
||||
border: 4rpx solid rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
&__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;
|
||||
}
|
||||
|
||||
&__member-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
padding: 6rpx 14rpx 6rpx 8rpx;
|
||||
border-radius: 999rpx;
|
||||
background: linear-gradient(135deg, rgba(244, 250, 253, 0.98), rgba(219, 235, 243, 0.94));
|
||||
border: 1rpx solid rgba(123, 165, 190, 0.22);
|
||||
box-shadow:
|
||||
inset 0 1rpx 0 rgba(255, 255, 255, 0.9),
|
||||
0 8rpx 20rpx rgba(79, 123, 148, 0.16);
|
||||
}
|
||||
|
||||
&__member-icon {
|
||||
width: 34rpx;
|
||||
height: 34rpx;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: radial-gradient(circle at 30% 30%, #ffffff 0%, #dfeef6 38%, #8fb6cb 100%);
|
||||
box-shadow:
|
||||
inset 0 2rpx 4rpx rgba(255, 255, 255, 0.76),
|
||||
0 3rpx 8rpx rgba(77, 117, 140, 0.18);
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 3rpx;
|
||||
border-radius: 50%;
|
||||
border: 1.5rpx solid rgba(92, 132, 156, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
&__member-letter {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
font-size: 20rpx;
|
||||
line-height: 1;
|
||||
font-weight: 800;
|
||||
color: #4f6f82;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
|
||||
&__member-label {
|
||||
font-size: 20rpx;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
color: #537488;
|
||||
letter-spacing: 2rpx;
|
||||
}
|
||||
|
||||
&__phone {
|
||||
font-size: 26rpx;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
// ── Loading state ──
|
||||
&__loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
&__avatar-skeleton {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
&__nickname-skeleton {
|
||||
width: 160rpx;
|
||||
height: 36rpx;
|
||||
border-radius: $radius-sm;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
&__phone-skeleton {
|
||||
width: 120rpx;
|
||||
height: 26rpx;
|
||||
border-radius: $radius-sm;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
// ── Stats row ──
|
||||
&__stats {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
margin: 0 $spacing-lg $spacing-lg;
|
||||
border-radius: $radius-lg;
|
||||
padding: $spacing-md 0;
|
||||
backdrop-filter: blur(10rpx);
|
||||
}
|
||||
|
||||
&__stat-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: $spacing-xs 0;
|
||||
}
|
||||
|
||||
&__stat-divider {
|
||||
width: 1rpx;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
margin: $spacing-xs 0;
|
||||
}
|
||||
|
||||
&__stat-value {
|
||||
font-size: 44rpx;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__stat-label {
|
||||
font-size: 22rpx;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-top: 6rpx;
|
||||
}
|
||||
margin: 20rpx 32rpx 0; padding: 28rpx; background: #f2e9e3; border-radius: 32rpx;
|
||||
&__user, &__loading { display: flex; align-items: center; gap: 20rpx; }
|
||||
&__avatar-wrap { width: 108rpx; height: 108rpx; flex-shrink: 0; }
|
||||
&__avatar-img { width: 100%; height: 100%; box-sizing: border-box; border-radius: 50%; border: 5rpx solid #fcf8f4; }
|
||||
&__info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10rpx; }
|
||||
&__name-row { display: flex; align-items: center; flex-wrap: wrap; gap: 10rpx; }
|
||||
&__nickname { font-size: 36rpx; font-weight: 500; line-height: 1.4; color: #514943; overflow-wrap: anywhere; }
|
||||
&__member-label { flex-shrink: 0; font-size: 19rpx; color: #8b6c5b; background: #fbf5ef; padding: 4rpx 12rpx; border-radius: 999rpx; }
|
||||
&__phone { font-size: 24rpx; color: #8b7b70; }
|
||||
&__edit { flex-shrink: 0; font-size: 22rpx; color: #8b7b70; padding: 16rpx 0 16rpx 8rpx; }
|
||||
&__stats { display: flex; align-items: stretch; margin-top: 28rpx; padding-top: 26rpx; border-top: 1rpx solid #e3d7ce; }
|
||||
&__stat-item { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 10rpx; }
|
||||
&__stat-value { font-size: 40rpx; font-weight: 400; color: #6f5c50; line-height: 1.15; font-variant-numeric: tabular-nums; }
|
||||
&__stat-label { font-size: 20rpx; color: #8b7b70; }
|
||||
&__stat-divider { width: 1rpx; margin: 6rpx 0; background: #e3d7ce; }
|
||||
&__guest { display: flex; align-items: center; flex-wrap: wrap; gap: 20rpx; }
|
||||
&__guest-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10rpx; }
|
||||
&__guest-title { font-size: 32rpx; font-weight: 500; color: #514943; }
|
||||
&__guest-sub { font-size: 23rpx; color: #8b7b70; line-height: 1.6; }
|
||||
&__login-btn { width: 100%; height: 76rpx; line-height: 76rpx; margin: 4rpx 0 0; padding: 0; border: none; border-radius: 999rpx; background: #6b8276; color: #fff; font-size: 26rpx; font-weight: 400; &::after { border: none; } }
|
||||
&__avatar-skeleton { width: 108rpx; height: 108rpx; border-radius: 50%; background: #e6d9ce; }
|
||||
&__nickname-skeleton { width: 150rpx; height: 32rpx; border-radius: 8rpx; background: #e6d9ce; }
|
||||
&__phone-skeleton { width: 180rpx; height: 22rpx; border-radius: 6rpx; background: #e6d9ce; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "普拉提约课",
|
||||
"appid": "",
|
||||
"appid": "wx3e7a133d2305fa2c",
|
||||
"description": "普拉提工作室约课小程序",
|
||||
"versionName": "0.1.0",
|
||||
"versionCode": "100",
|
||||
"transformPx": false,
|
||||
"mp-weixin": {
|
||||
"appid": "",
|
||||
"appid": "wx3e7a133d2305fa2c",
|
||||
"setting": {
|
||||
"urlCheck": false,
|
||||
"es6": true,
|
||||
|
||||
@@ -45,12 +45,24 @@
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/profile/teaching-schedule",
|
||||
"style": {
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/profile/info",
|
||||
"style": {
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/profile/invite",
|
||||
"style": {
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/teacher/detail",
|
||||
"style": {
|
||||
@@ -75,12 +87,6 @@
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/admin/week-template",
|
||||
"style": {
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/admin/slot-adjust",
|
||||
"style": {
|
||||
@@ -93,6 +99,28 @@
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/admin/member-detail",
|
||||
"style": {
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/admin/member-edit",
|
||||
"style": {
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/admin/member-supplement",
|
||||
"style": { "navigationStyle": "custom" }
|
||||
},
|
||||
{
|
||||
"path": "pages/admin/member-arrange",
|
||||
"style": {
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/admin/orders",
|
||||
"style": {
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import type { BookingWithUser, BookingStatusHistory } from '@mp-pilates/shared'
|
||||
import { BookingStatus } from '@mp-pilates/shared'
|
||||
import { useBookingStore } from '../../stores/booking'
|
||||
@@ -165,6 +166,7 @@ const bookingStore = useBookingStore()
|
||||
const navBarHeight = ref('64px')
|
||||
const refreshing = ref(false)
|
||||
const loading = ref(false)
|
||||
const hasLoadedOnce = ref(false)
|
||||
|
||||
// ─── Filter state ─────────────────────────────────────────────────────────
|
||||
type FilterValue = string | null
|
||||
@@ -215,9 +217,9 @@ function formatTimelineText(h: BookingStatusHistory): string {
|
||||
}
|
||||
|
||||
// ─── Data loading ─────────────────────────────────────────────────────────
|
||||
async function loadBookings(append = false) {
|
||||
async function loadBookings(append = false, opts: { silent?: boolean } = {}) {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
if (!opts.silent) loading.value = true
|
||||
|
||||
try {
|
||||
const page = append ? currentPage.value + 1 : 1
|
||||
@@ -234,8 +236,9 @@ async function loadBookings(append = false) {
|
||||
totalCount.value = result.total
|
||||
hasMore.value = bookings.value.length < result.total
|
||||
|
||||
// Fetch history for each booking
|
||||
if (!append) {
|
||||
// Fetch history for each booking. Skip in silent mode — history is only
|
||||
// shown as a small inline preview, and the detail page has the full one.
|
||||
if (!append && !opts.silent) {
|
||||
await Promise.all(
|
||||
bookings.value.map((b) => fetchHistory(b.id)),
|
||||
)
|
||||
@@ -247,9 +250,9 @@ async function loadBookings(append = false) {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Load bookings failed:', err)
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
if (!opts.silent) uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (!opts.silent) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,6 +394,24 @@ onMounted(() => {
|
||||
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||
loadBookings(false)
|
||||
loadAllForStats()
|
||||
hasLoadedOnce.value = true
|
||||
})
|
||||
|
||||
// After returning from booking detail (where status may have changed),
|
||||
// re-sync the list with the server. Local row actions still call onRefresh
|
||||
// directly — this onShow is the safety net for the navigate-back path.
|
||||
// Uses silent mode so the list stays visible (no skeleton flash).
|
||||
onShow(() => {
|
||||
if (!hasLoadedOnce.value) return
|
||||
// Skip while a refresh is already in flight to avoid overlap.
|
||||
if (refreshing.value || loading.value) return
|
||||
Promise.all([
|
||||
loadBookings(false, { silent: true }),
|
||||
loadAllForStats(),
|
||||
]).catch(() => {
|
||||
// Errors are non-fatal here — list keeps showing stale data until
|
||||
// the next explicit refresh.
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -150,7 +150,7 @@
|
||||
class="modal-input"
|
||||
type="number"
|
||||
v-model="form.totalTimesStr"
|
||||
placeholder="次卡必填,月卡留空"
|
||||
placeholder="次卡必填;月卡可填写次数"
|
||||
placeholder-style="color:#bbb"
|
||||
/>
|
||||
</view>
|
||||
@@ -188,6 +188,32 @@
|
||||
auto-height
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- Cover image upload -->
|
||||
<view class="modal-field modal-field--cover">
|
||||
<text class="modal-label">封面图</text>
|
||||
<view class="cover-upload-area">
|
||||
<view v-if="form.coverUrl" class="cover-preview-wrap">
|
||||
<image class="cover-preview-img" :src="form.coverUrl" mode="aspectFill" />
|
||||
<view class="cover-remove-btn" @tap="clearCover">
|
||||
<text class="cover-remove-icon">✕</text>
|
||||
</view>
|
||||
</view>
|
||||
<view
|
||||
v-else
|
||||
class="cover-upload-btn"
|
||||
:class="{ 'cover-upload-btn--loading': uploadingCover }"
|
||||
@tap="uploadCover"
|
||||
>
|
||||
<text v-if="uploadingCover" class="cover-upload-hint">上传中...</text>
|
||||
<template v-else>
|
||||
<text class="cover-upload-plus">+</text>
|
||||
<text class="cover-upload-hint">上传封面</text>
|
||||
</template>
|
||||
</view>
|
||||
<text class="cover-upload-tip">可选,建议 3:2 比例</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Action buttons -->
|
||||
@@ -215,6 +241,7 @@ import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
import { getSystemLayout } from '../../utils/system'
|
||||
import { useAdminStore } from '../../stores/admin'
|
||||
import { formatPrice } from '../../utils/format'
|
||||
import { uploadStudioAsset } from '../../utils/studio-upload'
|
||||
import { CardTypeCategory } from '@mp-pilates/shared'
|
||||
import type { CardType } from '@mp-pilates/shared'
|
||||
|
||||
@@ -229,6 +256,7 @@ const cardTypes = ref<CardType[]>([])
|
||||
const loading = ref(false)
|
||||
const showModal = ref(false)
|
||||
const submitting = ref(false)
|
||||
const uploadingCover = ref(false)
|
||||
const editTarget = ref<CardType | null>(null)
|
||||
|
||||
const typeOptions = [
|
||||
@@ -246,6 +274,7 @@ const defaultForm = () => ({
|
||||
durationDaysStr: '90',
|
||||
sortOrderStr: '0',
|
||||
description: '',
|
||||
coverUrl: '',
|
||||
})
|
||||
|
||||
const form = ref(defaultForm())
|
||||
@@ -282,6 +311,7 @@ function openEdit(ct: CardType) {
|
||||
durationDaysStr: String(ct.durationDays),
|
||||
sortOrderStr: String(ct.sortOrder),
|
||||
description: ct.description ?? '',
|
||||
coverUrl: ct.coverUrl ?? '',
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
@@ -349,6 +379,9 @@ async function submitForm() {
|
||||
if (form.value.description.trim()) {
|
||||
payload.description = form.value.description.trim()
|
||||
}
|
||||
if (form.value.coverUrl) {
|
||||
payload.coverUrl = form.value.coverUrl
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
@@ -431,6 +464,85 @@ function confirmDelete(ct: CardType) {
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Cover image upload ─────────────────────────────
|
||||
|
||||
async function uploadCover() {
|
||||
if (uploadingCover.value) return
|
||||
|
||||
try {
|
||||
const file = await chooseSingleImage()
|
||||
if (!file) return
|
||||
|
||||
uploadingCover.value = true
|
||||
const url = await uploadStudioAsset({
|
||||
adminStore,
|
||||
filePath: file.path,
|
||||
fileName: file.name,
|
||||
assetType: 'card-cover',
|
||||
})
|
||||
form.value.coverUrl = url
|
||||
uni.showToast({ title: '上传成功', icon: 'success' })
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : '上传失败'
|
||||
uni.showToast({ title: message, icon: 'none' })
|
||||
} finally {
|
||||
uploadingCover.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clearCover() {
|
||||
form.value.coverUrl = ''
|
||||
}
|
||||
|
||||
interface PickedImage {
|
||||
readonly path: string
|
||||
readonly name: string
|
||||
}
|
||||
|
||||
function extractFileName(filePath: string): string {
|
||||
return filePath.split('/').pop() || `image_${Date.now()}.jpg`
|
||||
}
|
||||
|
||||
function chooseSingleImage(): Promise<PickedImage | null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
sizeType: ['compressed'],
|
||||
sourceType: ['album', 'camera'],
|
||||
success: (result) => {
|
||||
const tempFilePaths = Array.isArray(result.tempFilePaths)
|
||||
? result.tempFilePaths
|
||||
: typeof result.tempFilePaths === 'string'
|
||||
? [result.tempFilePaths]
|
||||
: []
|
||||
const path = tempFilePaths[0]
|
||||
if (!path) {
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
|
||||
const tempFiles = Array.isArray(result.tempFiles)
|
||||
? result.tempFiles
|
||||
: result.tempFiles
|
||||
? [result.tempFiles]
|
||||
: []
|
||||
const file = tempFiles[0] as { path?: string; tempFilePath?: string; name?: string } | undefined
|
||||
resolve({
|
||||
path,
|
||||
name: file?.name || extractFileName(file?.path || file?.tempFilePath || path),
|
||||
})
|
||||
},
|
||||
fail: (error) => {
|
||||
if ((error.errMsg || '').includes('cancel')) {
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
reject(new Error(error.errMsg || '选择图片失败'))
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────
|
||||
|
||||
function typeLabel(ct: CardType): string {
|
||||
@@ -721,4 +833,82 @@ onMounted(fetchCardTypes)
|
||||
}
|
||||
|
||||
.modal-confirm-text { font-size: 28rpx; font-weight: 700; color: $primary-dark; }
|
||||
|
||||
/* ── Cover upload ───────────────────────── */
|
||||
.modal-field--cover {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16rpx;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.cover-upload-area {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.cover-preview-wrap {
|
||||
position: relative;
|
||||
width: 300rpx;
|
||||
height: 200rpx;
|
||||
border-radius: 12rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cover-preview-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cover-remove-btn {
|
||||
position: absolute;
|
||||
top: 8rpx;
|
||||
right: 8rpx;
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cover-remove-icon {
|
||||
font-size: 20rpx;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.cover-upload-btn {
|
||||
width: 300rpx;
|
||||
height: 200rpx;
|
||||
border: 2rpx dashed #ddd;
|
||||
border-radius: 12rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8rpx;
|
||||
background: #fafafa;
|
||||
|
||||
&:active { background: #f0f0f0; }
|
||||
&--loading { opacity: 0.6; pointer-events: none; }
|
||||
}
|
||||
|
||||
.cover-upload-plus {
|
||||
font-size: 48rpx;
|
||||
color: #bbb;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.cover-upload-hint {
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.cover-upload-tip {
|
||||
font-size: 20rpx;
|
||||
color: #bbb;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -316,7 +316,7 @@ async function loadData() {
|
||||
adminStore.fetchFlashSales(),
|
||||
adminStore.fetchCardTypes(),
|
||||
])
|
||||
items.value = [...salesResult.data]
|
||||
items.value = [...salesResult.items]
|
||||
total.value = salesResult.total
|
||||
cardTypes.value = [...cardTypesResult]
|
||||
} catch {
|
||||
@@ -329,7 +329,7 @@ async function loadData() {
|
||||
async function reloadSales() {
|
||||
try {
|
||||
const result = await adminStore.fetchFlashSales()
|
||||
items.value = [...result.data]
|
||||
items.value = [...result.items]
|
||||
total.value = result.total
|
||||
} catch {
|
||||
// silent
|
||||
|
||||
@@ -63,21 +63,6 @@
|
||||
<text class="arrow-text">›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="list-item" @tap="navigate('/pages/admin/week-template')">
|
||||
<view class="item-left">
|
||||
<view class="item-icon-wrap icon--template">
|
||||
<text class="item-icon-text">◈</text>
|
||||
</view>
|
||||
<view class="item-text-group">
|
||||
<text class="item-title">排课模板</text>
|
||||
<text class="item-desc">设置每周课程模板</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="item-arrow">
|
||||
<text class="arrow-text">›</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Section header: 会员与订单 -->
|
||||
|
||||
574
packages/app/src/pages/admin/member-arrange.vue
Normal file
574
packages/app/src/pages/admin/member-arrange.vue
Normal 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>
|
||||
408
packages/app/src/pages/admin/member-detail.vue
Normal file
408
packages/app/src/pages/admin/member-detail.vue
Normal file
@@ -0,0 +1,408 @@
|
||||
<template>
|
||||
<view class="page" :style="{ paddingTop: navBarHeight }">
|
||||
<CustomNavBar title="会员档案" show-back />
|
||||
|
||||
<view v-if="loading && !detail" class="skeleton-wrap">
|
||||
<view class="skeleton-hero" />
|
||||
<view class="skeleton-block" />
|
||||
<view class="skeleton-block" />
|
||||
</view>
|
||||
|
||||
<template v-else-if="detail">
|
||||
<view class="hero">
|
||||
<view class="hero-inner">
|
||||
<view class="hero-avatar">
|
||||
<image v-if="detail.user.avatarUrl" class="avatar-img" :src="detail.user.avatarUrl" mode="aspectFill" />
|
||||
<view v-else class="avatar-fallback">
|
||||
<text class="avatar-letter">{{ (detail.user.nickname || '?').slice(0, 1) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="hero-copy">
|
||||
<text class="hero-name">{{ detail.user.nickname || '未知用户' }}</text>
|
||||
<text class="hero-phone">{{ detail.user.phone || '未绑定手机' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="detail.user.openid" class="member-id" @tap="copyOpenid">
|
||||
<text class="member-id-label">微信标识</text>
|
||||
<text class="member-id-value">{{ detail.user.openid }}</text>
|
||||
<text class="member-id-copy">复制</text>
|
||||
</view>
|
||||
<view class="time-pair">
|
||||
<view class="time-cell">
|
||||
<text class="time-label">注册时间</text>
|
||||
<text class="time-value">{{ formatDateTimeFull(detail.user.createdAt) }}</text>
|
||||
</view>
|
||||
<view class="time-rule" />
|
||||
<view class="time-cell">
|
||||
<text class="time-label">最近登录</text>
|
||||
<text class="time-value">{{ detail.user.lastLoginAt ? formatDateTimeFull(detail.user.lastLoginAt) : '暂无登录记录' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section section--practice">
|
||||
<view class="section-heading">
|
||||
<text class="section-label">上课情况</text>
|
||||
<button class="supplement-entry" @tap="goSupplement">补录上课 <text>›</text></button>
|
||||
</view>
|
||||
<view class="stats-strip">
|
||||
<view class="stat">
|
||||
<text class="stat-num">{{ detail.stats.totalBookings }}</text>
|
||||
<text class="stat-name">累计预约</text>
|
||||
</view>
|
||||
<view class="stat stat--completed">
|
||||
<text class="stat-num">{{ detail.stats.completedBookings }}</text>
|
||||
<text class="stat-name">累计上课</text>
|
||||
</view>
|
||||
<view class="stat">
|
||||
<text class="stat-num">{{ detail.stats.cancelledBookings }}</text>
|
||||
<text class="stat-name">已取消</text>
|
||||
</view>
|
||||
<view class="stat">
|
||||
<text class="stat-num">{{ detail.stats.noShowBookings }}</text>
|
||||
<text class="stat-name">未到课</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section">
|
||||
<view class="section-heading">
|
||||
<text class="section-label">会员卡</text>
|
||||
<text class="section-note">{{ detail.memberships.length }} 张</text>
|
||||
</view>
|
||||
<view v-if="detail.memberships.length" class="card-list">
|
||||
<view
|
||||
v-for="card in detail.memberships"
|
||||
:key="card.id"
|
||||
class="mship"
|
||||
>
|
||||
<view class="mship-head">
|
||||
<view class="mship-titles">
|
||||
<text class="mship-name">{{ card.cardType.name }}</text>
|
||||
<text class="mship-type">{{ getCardTypeLabel(card.cardType.type) }}</text>
|
||||
</view>
|
||||
<view class="mship-status" :class="'mship-status--' + card.status.toLowerCase()">
|
||||
<text class="mship-status-text">{{ membershipStatusLabel(card.status) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="card.remainingTimes !== null" class="mship-times">
|
||||
<text class="mship-times-num">{{ card.remainingTimes }}</text>
|
||||
<text class="mship-times-unit">次可用</text>
|
||||
</view>
|
||||
<view v-else class="mship-duration">
|
||||
<text class="mship-duration-title">有效期内使用</text>
|
||||
<text class="mship-times-unit">不限次数</text>
|
||||
</view>
|
||||
<view v-if="card.remainingTimes !== null && getMembershipTotalTimes(card)" class="progress">
|
||||
<view class="progress-bar">
|
||||
<view class="progress-fill" :style="{ width: getMembershipProgressWidth(card) }" />
|
||||
</view>
|
||||
<text class="progress-text">
|
||||
已用 {{ getMembershipUsedTimes(card) }} / {{ getMembershipTotalTimes(card) }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="mship-dates">
|
||||
<text>{{ formatDate(card.startDate) }} 起</text>
|
||||
<text>{{ formatDate(card.expireDate) }} 止</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="empty-card">
|
||||
<text class="empty-card-title">尚未开卡</text>
|
||||
<text class="empty-card-sub">开通体验卡、次卡或月卡后即可安排课程</text>
|
||||
<view class="empty-card-btn" @tap="goEdit">
|
||||
<text class="empty-card-btn-text">去开卡</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section section--last">
|
||||
<view class="section-heading">
|
||||
<text class="section-label">即将上课</text>
|
||||
<text v-if="detail.upcomingBookings.length" class="section-note">{{ detail.upcomingBookings.length }} 节待上</text>
|
||||
</view>
|
||||
<view v-if="detail.upcomingBookings.length" class="upcoming-list">
|
||||
<view v-for="item in detail.upcomingBookings" :key="item.id" class="upcoming-row">
|
||||
<view class="upcoming-time">
|
||||
<text class="upcoming-date">{{ item.date.slice(5, 10).replace('-', ' / ') }}</text>
|
||||
<text class="upcoming-hour">{{ item.startTime.slice(0, 5) }}–{{ item.endTime.slice(0, 5) }}</text>
|
||||
</view>
|
||||
<view class="upcoming-meta">
|
||||
<text class="upcoming-card">{{ item.cardName }}</text>
|
||||
<text class="upcoming-status">{{ bookingStatusLabel(item.status) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="upcoming-empty">
|
||||
<text class="upcoming-empty-text">近期没有待上的课</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<view class="dock">
|
||||
<view class="dock-btn dock-btn--ghost" @tap="goEdit">
|
||||
<text class="dock-btn-text">编辑资料</text>
|
||||
</view>
|
||||
<view
|
||||
class="dock-btn dock-btn--solid"
|
||||
:class="{ 'dock-btn--disabled': !canArrange }"
|
||||
@tap="goArrange"
|
||||
>
|
||||
<text class="dock-btn-text dock-btn-text--solid">安排课程</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
import type { AdminMemberDetail, MembershipWithCardType } from '@mp-pilates/shared'
|
||||
import { MembershipStatus, BookingStatus } from '@mp-pilates/shared'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
import { getSystemLayout } from '../../utils/system'
|
||||
import {
|
||||
formatDateTimeFull,
|
||||
getCardTypeLabel,
|
||||
getMembershipProgressWidth,
|
||||
getMembershipUsedTimes,
|
||||
getMembershipTotalTimes,
|
||||
} from '../../utils/format'
|
||||
import { BOOKING_STATUS_LABELS } from '../../utils/booking-helpers'
|
||||
import { getErrorMessage } from '../../utils/auth'
|
||||
import { useAdminStore } from '../../stores/admin'
|
||||
|
||||
const adminStore = useAdminStore()
|
||||
const navBarHeight = ref('64px')
|
||||
const userId = ref('')
|
||||
const loading = ref(false)
|
||||
const detail = ref<AdminMemberDetail | null>(null)
|
||||
|
||||
const canArrange = computed(() => (detail.value?.memberships ?? []).some(isArrangableMembership))
|
||||
|
||||
function isArrangableMembership(membership: MembershipWithCardType): boolean {
|
||||
if (membership.status !== MembershipStatus.ACTIVE) return false
|
||||
if (membership.remainingTimes !== null && membership.remainingTimes <= 0) return false
|
||||
return new Date(membership.expireDate) > new Date()
|
||||
}
|
||||
|
||||
function membershipStatusLabel(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
ACTIVE: '有效',
|
||||
EXPIRED: '已过期',
|
||||
USED_UP: '已用完',
|
||||
}
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
function bookingStatusLabel(status: BookingStatus): string {
|
||||
return BOOKING_STATUS_LABELS[status] || status
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return dateStr.slice(0, 10)
|
||||
}
|
||||
|
||||
function copyOpenid() {
|
||||
const openid = detail.value?.user.openid
|
||||
if (!openid) return
|
||||
uni.setClipboardData({
|
||||
data: openid,
|
||||
success: () => uni.showToast({ title: '已复制 OpenID', icon: 'success' }),
|
||||
})
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
if (!userId.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
detail.value = await adminStore.fetchMemberDetail(userId.value)
|
||||
} catch (err: unknown) {
|
||||
uni.showToast({ title: getErrorMessage(err, '加载失败'), icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goSupplement() {
|
||||
if (userId.value) uni.navigateTo({ url: `/pages/admin/member-supplement?userId=${userId.value}` })
|
||||
}
|
||||
|
||||
function goEdit() {
|
||||
if (!userId.value) return
|
||||
uni.navigateTo({ url: `/pages/admin/member-edit?userId=${userId.value}` })
|
||||
}
|
||||
|
||||
function goArrange() {
|
||||
if (!canArrange.value) {
|
||||
uni.showToast({ title: '请先开通有效会员卡', icon: 'none' })
|
||||
return
|
||||
}
|
||||
uni.navigateTo({ url: `/pages/admin/member-arrange?userId=${userId.value}` })
|
||||
}
|
||||
|
||||
onLoad((query) => {
|
||||
userId.value = String(query?.userId || '')
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
if (userId.value) {
|
||||
loadDetail()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page {
|
||||
--ink: #514943;
|
||||
--muted: #8b817b;
|
||||
--line: #eee8e3;
|
||||
--sage: #617d73;
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
background: #fbf9f6;
|
||||
color: var(--ink);
|
||||
padding-bottom: calc(152rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.skeleton-wrap { padding: 28rpx 32rpx; }
|
||||
.skeleton-hero, .skeleton-block {
|
||||
border-radius: 28rpx;
|
||||
background: linear-gradient(90deg, #f0eae5 25%, #faf7f3 50%, #f0eae5 75%);
|
||||
background-size: 400% 100%;
|
||||
animation: shimmer 1.4s infinite;
|
||||
}
|
||||
.skeleton-hero { height: 300rpx; margin-bottom: 28rpx; }
|
||||
.skeleton-block { height: 180rpx; margin-bottom: 28rpx; }
|
||||
|
||||
.hero {
|
||||
margin: 28rpx 32rpx 0;
|
||||
padding: 32rpx;
|
||||
border-radius: 32rpx;
|
||||
background: #f3eae5;
|
||||
}
|
||||
.hero-inner { display: flex; align-items: center; gap: 24rpx; }
|
||||
.hero-avatar {
|
||||
width: 112rpx;
|
||||
height: 112rpx;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
border: 6rpx solid #fcf8f4;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.avatar-img { width: 100%; height: 100%; }
|
||||
.avatar-fallback {
|
||||
width: 100%; height: 100%; background: #e0cec4;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.avatar-letter {
|
||||
font-family: 'Songti SC', 'STSong', serif;
|
||||
font-size: 44rpx; font-weight: 400; color: #735e53;
|
||||
}
|
||||
.hero-copy { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 12rpx; }
|
||||
.hero-name { font-size: 38rpx; font-weight: 500; line-height: 1.4; overflow-wrap: anywhere; }
|
||||
.hero-phone { font-size: 26rpx; color: #84746b; letter-spacing: 1rpx; }
|
||||
.member-id {
|
||||
display: flex; align-items: center; gap: 14rpx;
|
||||
margin-top: 24rpx; min-height: 48rpx;
|
||||
font-size: 20rpx; color: #88786f;
|
||||
}
|
||||
.member-id-label { flex-shrink: 0; }
|
||||
.member-id-value { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.member-id-copy { flex-shrink: 0; color: #715a4e; padding: 8rpx 0 8rpx 8rpx; }
|
||||
.time-pair {
|
||||
display: flex; gap: 20rpx;
|
||||
margin-top: 20rpx; padding-top: 24rpx;
|
||||
border-top: 1rpx solid #e4d8d0;
|
||||
}
|
||||
.time-cell { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10rpx; }
|
||||
.time-rule { width: 1rpx; background: #e4d8d0; }
|
||||
.time-label { font-size: 21rpx; color: #88786f; }
|
||||
.time-value { font-size: 22rpx; color: #6d5c52; line-height: 1.5; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.section { padding: 36rpx 32rpx 0; }
|
||||
.section--last { padding-bottom: 24rpx; }
|
||||
.section-label { display: block; font-size: 28rpx; font-weight: 500; margin-bottom: 20rpx; }
|
||||
.section-heading { display: flex; justify-content: space-between; align-items: baseline; gap: 16rpx; }
|
||||
.supplement-entry { margin: 0; padding: 8rpx 18rpx; line-height: 1.5; font-size: 23rpx; border-radius: 999rpx; background: #edf2ec; color: #617d73; &::after { border: none; } }
|
||||
.section-note { font-size: 22rpx; color: var(--muted); }
|
||||
.stats-strip {
|
||||
display: flex; padding: 26rpx 0;
|
||||
border-radius: 24rpx; background: #ffffff;
|
||||
}
|
||||
.stat {
|
||||
flex: 1; min-width: 0; display: flex; flex-direction: column;
|
||||
align-items: center; gap: 12rpx; border-right: 1rpx solid var(--line);
|
||||
&:last-child { border-right: none; }
|
||||
}
|
||||
.stat-num { font-size: 40rpx; font-weight: 400; line-height: 1.1; font-family: 'DIN Alternate', 'Avenir Next', sans-serif; font-variant-numeric: tabular-nums; }
|
||||
.stat-name { font-size: 22rpx; color: var(--muted); }
|
||||
.stat--completed { .stat-num, .stat-name { color: var(--sage); } }
|
||||
|
||||
.card-list { display: flex; flex-direction: column; gap: 20rpx; }
|
||||
.mship { padding: 28rpx; background: #fff; border-radius: 28rpx; border: 1rpx solid var(--line); }
|
||||
.mship-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 20rpx; }
|
||||
.mship-titles { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
|
||||
.mship-name { font-size: 29rpx; font-weight: 500; line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.mship-type { font-size: 21rpx; color: var(--muted); }
|
||||
.mship-status {
|
||||
flex-shrink: 0; padding: 6rpx 16rpx; border-radius: 999rpx;
|
||||
background: #edf2ee; color: #617d73; line-height: 1.3;
|
||||
&--expired, &--used_up { background: #f2efec; color: #8b817b; }
|
||||
}
|
||||
.mship-status-text { font-size: 21rpx; }
|
||||
.mship-times { margin-top: 22rpx; display: flex; align-items: baseline; gap: 10rpx; }
|
||||
.mship-times-num { font-size: 52rpx; font-weight: 400; font-family: 'DIN Alternate', 'Avenir Next', sans-serif; line-height: 1.2; }
|
||||
.mship-times-unit { font-size: 22rpx; color: var(--muted); }
|
||||
.mship-duration { display: flex; align-items: baseline; flex-wrap: wrap; gap: 12rpx; margin-top: 24rpx; }
|
||||
.mship-duration-title { font-size: 28rpx; color: #617d73; }
|
||||
.progress { margin-top: 18rpx; }
|
||||
.progress-bar { height: 6rpx; border-radius: 6rpx; background: #f0efea; overflow: hidden; }
|
||||
.progress-fill { height: 100%; border-radius: 6rpx; background: #a5b8ab; }
|
||||
.progress-text { display: block; margin-top: 10rpx; font-size: 20rpx; color: var(--muted); }
|
||||
.mship-dates {
|
||||
margin-top: 22rpx; padding-top: 18rpx; border-top: 1rpx solid #f3efeb;
|
||||
display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8rpx 20rpx;
|
||||
font-size: 21rpx; color: var(--muted); line-height: 1.5;
|
||||
}
|
||||
.empty-card {
|
||||
background: #f4f2ed; border-radius: 28rpx; padding: 36rpx 28rpx;
|
||||
display: flex; flex-direction: column; align-items: flex-start; gap: 12rpx;
|
||||
}
|
||||
.empty-card-title { font-size: 28rpx; font-weight: 500; }
|
||||
.empty-card-sub { font-size: 23rpx; color: var(--muted); line-height: 1.7; }
|
||||
.empty-card-btn { margin-top: 10rpx; padding: 14rpx 28rpx; border-radius: 999rpx; background: #e4ebe4; }
|
||||
.empty-card-btn-text { font-size: 24rpx; color: #526e62; }
|
||||
|
||||
.upcoming-list { background: #fff; border-radius: 28rpx; padding: 0 28rpx; }
|
||||
.upcoming-row {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 24rpx;
|
||||
padding: 26rpx 0; border-bottom: 1rpx solid var(--line);
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
.upcoming-time { flex-shrink: 0; display: flex; flex-direction: column; gap: 10rpx; }
|
||||
.upcoming-date { font-size: 28rpx; font-weight: 500; }
|
||||
.upcoming-hour { font-size: 23rpx; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
.upcoming-meta { min-width: 0; display: flex; flex-direction: column; align-items: flex-end; gap: 10rpx; }
|
||||
.upcoming-card { font-size: 24rpx; text-align: right; overflow-wrap: anywhere; }
|
||||
.upcoming-status { font-size: 21rpx; color: var(--sage); }
|
||||
.upcoming-empty { padding: 32rpx 28rpx; background: #f4f2ed; border-radius: 24rpx; }
|
||||
.upcoming-empty-text { font-size: 24rpx; color: var(--muted); }
|
||||
|
||||
.dock {
|
||||
position: fixed; z-index: 10; left: 0; right: 0; bottom: 0;
|
||||
display: flex; gap: 20rpx;
|
||||
padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom));
|
||||
background: #fbf9f6; border-top: 1rpx solid var(--line);
|
||||
}
|
||||
.dock-btn { flex: 1; height: 88rpx; border-radius: 999rpx; display: flex; align-items: center; justify-content: center; }
|
||||
.dock-btn--ghost { background: #f0eae4; }
|
||||
.dock-btn--solid { flex: 1.35; background: #6b8276; }
|
||||
.dock-btn--disabled { background: #d9dfd8; .dock-btn-text--solid { color: #677467; } }
|
||||
.dock-btn-text { font-size: 28rpx; font-weight: 500; color: #78675c; }
|
||||
.dock-btn-text--solid { color: #fff; }
|
||||
</style>
|
||||
435
packages/app/src/pages/admin/member-edit.vue
Normal file
435
packages/app/src/pages/admin/member-edit.vue
Normal 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>
|
||||
211
packages/app/src/pages/admin/member-supplement.vue
Normal file
211
packages/app/src/pages/admin/member-supplement.vue
Normal 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">可填写 1–999 节,无需逐节创建预约</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
@@ -15,7 +15,7 @@
|
||||
<view v-else-if="editableSlots.length === 0" class="empty-state">
|
||||
<text class="empty-icon">📭</text>
|
||||
<text class="empty-text">当日暂无排课</text>
|
||||
<text class="empty-sub">无模板匹配,请手动添加时段或先配置排课模板</text>
|
||||
<text class="empty-sub">当日暂无默认时段,请点击下方按钮手动添加</text>
|
||||
</view>
|
||||
|
||||
<!-- Slot list -->
|
||||
@@ -40,9 +40,10 @@
|
||||
<view class="slot-body">
|
||||
<view class="time-section">
|
||||
<picker
|
||||
mode="time"
|
||||
:value="slot.startTime"
|
||||
@change="(e: any) => updateSlotTime(slot, 'startTime', e.detail.value)"
|
||||
mode="multiSelector"
|
||||
:range="timePickerRange"
|
||||
:value="timeToPickerIndex(slot.startTime)"
|
||||
@change="(e: any) => updateSlotTime(slot, 'startTime', pickerIndexToTime(e.detail.value))"
|
||||
>
|
||||
<view class="time-display">
|
||||
<text class="time-text">{{ slot.startTime }}</text>
|
||||
@@ -50,9 +51,10 @@
|
||||
</picker>
|
||||
<text class="time-separator">–</text>
|
||||
<picker
|
||||
mode="time"
|
||||
:value="slot.endTime"
|
||||
@change="(e: any) => updateSlotTime(slot, 'endTime', e.detail.value)"
|
||||
mode="multiSelector"
|
||||
:range="timePickerRange"
|
||||
:value="timeToPickerIndex(slot.endTime)"
|
||||
@change="(e: any) => updateSlotTime(slot, 'endTime', pickerIndexToTime(e.detail.value))"
|
||||
>
|
||||
<view class="time-display">
|
||||
<text class="time-text">{{ slot.endTime }}</text>
|
||||
@@ -112,8 +114,9 @@
|
||||
<view class="modal-field">
|
||||
<text class="modal-label">开始时间</text>
|
||||
<picker
|
||||
mode="time"
|
||||
:value="addForm.startTime"
|
||||
mode="multiSelector"
|
||||
:range="timePickerRange"
|
||||
:value="timeToPickerIndex(addForm.startTime)"
|
||||
@change="onAddStartTimeChange"
|
||||
>
|
||||
<view class="picker-display">
|
||||
@@ -162,6 +165,14 @@ import { getSystemLayout } from '../../utils/system'
|
||||
import { useAdminStore } from '../../stores/admin'
|
||||
import { formatDate } from '../../utils/format'
|
||||
import DateSelector from '../../components/DateSelector.vue'
|
||||
import {
|
||||
SCHEDULE_TIME_PICKER_RANGE,
|
||||
timeToPickerIndex,
|
||||
pickerIndexToTime,
|
||||
addOneHourCapped,
|
||||
} from '../../utils/schedule-time'
|
||||
|
||||
const timePickerRange = SCHEDULE_TIME_PICKER_RANGE
|
||||
|
||||
interface EditableSlot {
|
||||
readonly key: string
|
||||
@@ -186,8 +197,8 @@ const showAddModal = ref(false)
|
||||
const editableSlots = ref<EditableSlot[]>([])
|
||||
|
||||
const addForm = ref({
|
||||
startTime: '09:00',
|
||||
endTime: '10:00',
|
||||
startTime: '09:30',
|
||||
endTime: '10:30',
|
||||
capacityStr: '1',
|
||||
})
|
||||
|
||||
@@ -270,23 +281,14 @@ function removeSlot(slot: EditableSlot) {
|
||||
|
||||
// ── Add slot ──────────────────────────────────────────────
|
||||
|
||||
/** 将 "HH:mm" 加一小时,最大 23:59 */
|
||||
function addOneHour(time: string): string {
|
||||
const [h, m] = time.split(':').map(Number)
|
||||
const newH = Math.min(h + 1, 23)
|
||||
// 如果原本就是 23:xx,结束时间设为 23:59
|
||||
if (h >= 23) return '23:59'
|
||||
return String(newH).padStart(2, '0') + ':' + String(m).padStart(2, '0')
|
||||
}
|
||||
|
||||
function onAddStartTimeChange(e: any) {
|
||||
const start = e.detail.value as string
|
||||
const start = pickerIndexToTime(e.detail.value as number[])
|
||||
addForm.value.startTime = start
|
||||
addForm.value.endTime = addOneHour(start)
|
||||
addForm.value.endTime = addOneHourCapped(start)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -305,6 +307,10 @@ function submitAdd() {
|
||||
uni.showToast({ title: '请选择时间', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (addForm.value.startTime >= addForm.value.endTime) {
|
||||
uni.showToast({ title: '结束时间须晚于开始时间', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (isNaN(capacity) || capacity < 1) {
|
||||
uni.showToast({ title: '请填写有效容量', icon: 'none' })
|
||||
return
|
||||
@@ -404,7 +410,7 @@ function slotBadgeClass(slot: EditableSlot): string {
|
||||
function slotBadgeText(slot: EditableSlot): string {
|
||||
if (slot.isNew) return '新增'
|
||||
if (slot.isPublished) return '已发布'
|
||||
return '来自模板'
|
||||
return '默认时段'
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────
|
||||
|
||||
@@ -28,7 +28,12 @@
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<text class="form-label">开始时间</text>
|
||||
<picker mode="time" :value="addForm.startTime" @change="(e: any) => addForm.startTime = e.detail.value">
|
||||
<picker
|
||||
mode="multiSelector"
|
||||
:range="timePickerRange"
|
||||
:value="timeToPickerIndex(addForm.startTime)"
|
||||
@change="(e: any) => addForm.startTime = pickerIndexToTime(e.detail.value)"
|
||||
>
|
||||
<view class="picker-display">
|
||||
<text class="picker-text">{{ addForm.startTime || '请选择' }}</text>
|
||||
<text class="picker-arrow">›</text>
|
||||
@@ -37,7 +42,12 @@
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<text class="form-label">结束时间</text>
|
||||
<picker mode="time" :value="addForm.endTime" @change="(e: any) => addForm.endTime = e.detail.value">
|
||||
<picker
|
||||
mode="multiSelector"
|
||||
:range="timePickerRange"
|
||||
:value="timeToPickerIndex(addForm.endTime)"
|
||||
@change="(e: any) => addForm.endTime = pickerIndexToTime(e.detail.value)"
|
||||
>
|
||||
<view class="picker-display">
|
||||
<text class="picker-text">{{ addForm.endTime || '请选择' }}</text>
|
||||
<text class="picker-arrow">›</text>
|
||||
@@ -128,7 +138,7 @@
|
||||
</picker>
|
||||
</view>
|
||||
</view>
|
||||
<text class="gen-hint">将根据排课模板,自动生成所选日期范围内的时段</text>
|
||||
<text class="gen-hint">将按默认时间表(8:00-9:00,之后从 9:30 起每小时一节至 21:30)自动生成所选日期范围内的时段</text>
|
||||
<view class="action-wrap">
|
||||
<view class="action-btn" :class="{ 'action-btn--loading': submitting }" @tap="submitGenerate">
|
||||
<text class="action-btn-text">{{ submitting ? '生成中...' : '批量生成' }}</text>
|
||||
@@ -145,6 +155,13 @@ import { getSystemLayout } from '../../utils/system'
|
||||
import { useAdminStore } from '../../stores/admin'
|
||||
import { formatDate } from '../../utils/format'
|
||||
import type { TimeSlot } from '@mp-pilates/shared'
|
||||
import {
|
||||
SCHEDULE_TIME_PICKER_RANGE,
|
||||
timeToPickerIndex,
|
||||
pickerIndexToTime,
|
||||
} from '../../utils/schedule-time'
|
||||
|
||||
const timePickerRange = SCHEDULE_TIME_PICKER_RANGE
|
||||
|
||||
const adminStore = useAdminStore()
|
||||
|
||||
@@ -157,8 +174,8 @@ const slotsLoading = ref(false)
|
||||
// ── Add slot form ────────────────────────────────────────────────
|
||||
const addForm = ref({
|
||||
date: formatDate(new Date()),
|
||||
startTime: '09:00',
|
||||
endTime: '10:00',
|
||||
startTime: '09:30',
|
||||
endTime: '10:30',
|
||||
capacityStr: '10',
|
||||
})
|
||||
|
||||
@@ -168,6 +185,10 @@ async function submitAddSlot() {
|
||||
uni.showToast({ title: '请填写完整信息', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (addForm.value.startTime >= addForm.value.endTime) {
|
||||
uni.showToast({ title: '结束时间须晚于开始时间', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const capacity = parseInt(addForm.value.capacityStr, 10)
|
||||
submitting.value = true
|
||||
try {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,528 +0,0 @@
|
||||
<template>
|
||||
<view class="page" :style="{ paddingTop: navBarHeight }">
|
||||
<CustomNavBar title="排课模板" show-back />
|
||||
<!-- Toolbar -->
|
||||
<view class="toolbar">
|
||||
<text class="toolbar-hint">共 {{ templates.length }} 条模板</text>
|
||||
<view class="add-btn" @tap="openAdd">
|
||||
<text class="add-btn-text">+ 新增时段</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Loading skeleton -->
|
||||
<view v-if="loading" class="skeleton-list">
|
||||
<view v-for="i in 5" :key="i" class="skeleton-item" />
|
||||
</view>
|
||||
|
||||
<!-- Empty -->
|
||||
<view v-else-if="!templates.length" class="empty-state">
|
||||
<text class="empty-icon">📅</text>
|
||||
<text class="empty-text">暂无模板,点击右上角新增</text>
|
||||
</view>
|
||||
|
||||
<!-- Template list grouped by weekday -->
|
||||
<view v-else>
|
||||
<view v-for="(group, day) in grouped" :key="day" class="day-group">
|
||||
<view class="day-header">
|
||||
<text class="day-label">{{ WEEKDAY_LABELS[Number(day)] }}</text>
|
||||
<text class="day-count">{{ group.length }} 个时段</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="tpl in group"
|
||||
:key="tpl.id ?? tpl._key"
|
||||
class="tpl-row"
|
||||
:class="{ 'tpl-row--inactive': !tpl.isActive }"
|
||||
>
|
||||
<view class="tpl-time">
|
||||
<text class="tpl-time-text">{{ tpl.startTime }} – {{ tpl.endTime }}</text>
|
||||
<text class="tpl-capacity">{{ tpl.capacity }} 人</text>
|
||||
</view>
|
||||
<view class="tpl-actions">
|
||||
<view
|
||||
class="tpl-toggle"
|
||||
:class="tpl.isActive ? 'toggle--on' : 'toggle--off'"
|
||||
@tap="toggleTemplate(tpl)"
|
||||
>
|
||||
<text class="tpl-toggle-text">{{ tpl.isActive ? '启用' : '停用' }}</text>
|
||||
</view>
|
||||
<view class="tpl-edit" @tap="openEdit(tpl)">
|
||||
<text class="tpl-edit-text">编辑</text>
|
||||
</view>
|
||||
<view class="tpl-delete" @tap="deleteTemplate(tpl)">
|
||||
<text class="tpl-delete-text">删除</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Save bar -->
|
||||
<view v-if="isDirty" class="save-bar">
|
||||
<view class="save-btn" :class="{ 'save-btn--loading': saving }" @tap="handleSave">
|
||||
<text class="save-btn-text">{{ saving ? '保存中...' : '保存全部更改' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Add / Edit modal -->
|
||||
<view v-if="showModal" class="modal-mask" @tap.self="closeModal">
|
||||
<view class="modal">
|
||||
<text class="modal-title">{{ editTarget ? '编辑时段' : '新增时段' }}</text>
|
||||
|
||||
<view class="modal-field">
|
||||
<text class="modal-label">星期</text>
|
||||
<picker
|
||||
mode="selector"
|
||||
:range="dayOptions"
|
||||
range-key="label"
|
||||
:value="form.dayIdx"
|
||||
@change="(e: any) => form.dayIdx = Number(e.detail.value)"
|
||||
>
|
||||
<view class="picker-display">
|
||||
<text class="picker-text">{{ dayOptions[form.dayIdx].label }}</text>
|
||||
<text class="picker-arrow">›</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="modal-field">
|
||||
<text class="modal-label">开始时间</text>
|
||||
<picker
|
||||
mode="time"
|
||||
:value="form.startTime"
|
||||
@change="(e: any) => form.startTime = e.detail.value"
|
||||
>
|
||||
<view class="picker-display">
|
||||
<text class="picker-text">{{ form.startTime || '请选择' }}</text>
|
||||
<text class="picker-arrow">›</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="modal-field">
|
||||
<text class="modal-label">结束时间</text>
|
||||
<picker
|
||||
mode="time"
|
||||
:value="form.endTime"
|
||||
@change="(e: any) => form.endTime = e.detail.value"
|
||||
>
|
||||
<view class="picker-display">
|
||||
<text class="picker-text">{{ form.endTime || '请选择' }}</text>
|
||||
<text class="picker-arrow">›</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="modal-field modal-field--last">
|
||||
<text class="modal-label">容量</text>
|
||||
<input
|
||||
class="modal-input"
|
||||
type="number"
|
||||
v-model="form.capacityStr"
|
||||
placeholder="如:10"
|
||||
placeholder-style="color:#bbb"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="modal-actions">
|
||||
<view class="modal-cancel" @tap="closeModal">
|
||||
<text class="modal-cancel-text">取消</text>
|
||||
</view>
|
||||
<view class="modal-confirm" @tap="submitForm">
|
||||
<text class="modal-confirm-text">确认</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
import { getSystemLayout } from '../../utils/system'
|
||||
import { useAdminStore } from '../../stores/admin'
|
||||
import { WEEKDAY_LABELS } from '@mp-pilates/shared'
|
||||
import type { WeekTemplate } from '@mp-pilates/shared'
|
||||
|
||||
type LocalTemplate = Partial<WeekTemplate> & {
|
||||
_key?: string
|
||||
dayOfWeek: number
|
||||
startTime: string
|
||||
endTime: string
|
||||
capacity: number
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
const adminStore = useAdminStore()
|
||||
const navBarHeight = ref('64px')
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const isDirty = ref(false)
|
||||
const showModal = ref(false)
|
||||
const editTarget = ref<LocalTemplate | null>(null)
|
||||
|
||||
const templates = ref<LocalTemplate[]>([])
|
||||
|
||||
const dayOptions = [1, 2, 3, 4, 5, 6, 7].map((d) => ({ label: WEEKDAY_LABELS[d], value: d }))
|
||||
|
||||
const form = ref({
|
||||
dayIdx: 0,
|
||||
startTime: '08:00',
|
||||
endTime: '09:00',
|
||||
capacityStr: '1',
|
||||
})
|
||||
|
||||
const grouped = computed(() => {
|
||||
const map: Record<number, LocalTemplate[]> = {}
|
||||
for (const tpl of templates.value) {
|
||||
if (!map[tpl.dayOfWeek]) map[tpl.dayOfWeek] = []
|
||||
map[tpl.dayOfWeek].push(tpl)
|
||||
}
|
||||
// Sort by day
|
||||
return Object.fromEntries(
|
||||
Object.entries(map).sort(([a], [b]) => Number(a) - Number(b)),
|
||||
)
|
||||
})
|
||||
|
||||
/** 生成默认模板:周一到周日,8:00-22:00 每小时一个时段 */
|
||||
function generateDefaultTemplates(): LocalTemplate[] {
|
||||
const defaults: LocalTemplate[] = []
|
||||
for (let day = 1; day <= 7; day++) {
|
||||
for (let hour = 8; hour < 22; hour++) {
|
||||
const start = String(hour).padStart(2, '0') + ':00'
|
||||
const end = String(hour + 1).padStart(2, '0') + ':00'
|
||||
defaults.push({
|
||||
_key: `default-${day}-${start}`,
|
||||
dayOfWeek: day,
|
||||
startTime: start,
|
||||
endTime: end,
|
||||
capacity: 1,
|
||||
isActive: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
return defaults
|
||||
}
|
||||
|
||||
async function fetchTemplates() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await adminStore.fetchWeekTemplates()
|
||||
if (data.length === 0) {
|
||||
// No templates yet — pre-fill with defaults
|
||||
templates.value = generateDefaultTemplates()
|
||||
isDirty.value = true
|
||||
} else {
|
||||
templates.value = data
|
||||
isDirty.value = false
|
||||
}
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
editTarget.value = null
|
||||
form.value = { dayIdx: 0, startTime: '08:00', endTime: '09:00', capacityStr: '1' }
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function openEdit(tpl: LocalTemplate) {
|
||||
editTarget.value = tpl
|
||||
const dayIdx = dayOptions.findIndex((d) => d.value === tpl.dayOfWeek)
|
||||
form.value = {
|
||||
dayIdx: dayIdx >= 0 ? dayIdx : 0,
|
||||
startTime: tpl.startTime,
|
||||
endTime: tpl.endTime,
|
||||
capacityStr: String(tpl.capacity),
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal.value = false
|
||||
editTarget.value = null
|
||||
}
|
||||
|
||||
function submitForm() {
|
||||
const capacity = parseInt(form.value.capacityStr, 10)
|
||||
if (!form.value.startTime || !form.value.endTime) {
|
||||
uni.showToast({ title: '请填写时间', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (isNaN(capacity) || capacity < 1) {
|
||||
uni.showToast({ title: '请填写有效容量', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
const day = dayOptions[form.value.dayIdx].value
|
||||
|
||||
if (editTarget.value) {
|
||||
const tpl = editTarget.value
|
||||
tpl.dayOfWeek = day
|
||||
tpl.startTime = form.value.startTime
|
||||
tpl.endTime = form.value.endTime
|
||||
tpl.capacity = capacity
|
||||
} else {
|
||||
templates.value.push({
|
||||
_key: String(Date.now()),
|
||||
dayOfWeek: day,
|
||||
startTime: form.value.startTime,
|
||||
endTime: form.value.endTime,
|
||||
capacity,
|
||||
isActive: true,
|
||||
})
|
||||
}
|
||||
|
||||
isDirty.value = true
|
||||
closeModal()
|
||||
}
|
||||
|
||||
function toggleTemplate(tpl: LocalTemplate) {
|
||||
tpl.isActive = !tpl.isActive
|
||||
isDirty.value = true
|
||||
}
|
||||
|
||||
function deleteTemplate(tpl: LocalTemplate) {
|
||||
uni.showModal({
|
||||
title: '确认删除',
|
||||
content: '删除该时段模板?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
const idx = templates.value.indexOf(tpl)
|
||||
if (idx >= 0) templates.value.splice(idx, 1)
|
||||
isDirty.value = true
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (saving.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = templates.value.map((t) => ({
|
||||
id: t.id,
|
||||
dayOfWeek: t.dayOfWeek,
|
||||
startTime: t.startTime,
|
||||
endTime: t.endTime,
|
||||
capacity: t.capacity,
|
||||
isActive: t.isActive,
|
||||
}))
|
||||
await adminStore.saveWeekTemplates(payload as any)
|
||||
isDirty.value = false
|
||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||
await fetchTemplates()
|
||||
} catch (e: any) {
|
||||
uni.showToast({ title: e?.message ?? '保存失败', icon: 'none' })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||
fetchTemplates()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: #f5f3f0;
|
||||
padding-bottom: 120rpx;
|
||||
}
|
||||
|
||||
/* ── Toolbar ─────────────────────────────── */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24rpx 24rpx 16rpx;
|
||||
}
|
||||
|
||||
.toolbar-hint { font-size: 24rpx; color: #999; }
|
||||
|
||||
.add-btn {
|
||||
background: #1a1a2e;
|
||||
border-radius: 32rpx;
|
||||
padding: 12rpx 28rpx;
|
||||
}
|
||||
|
||||
.add-btn-text { font-size: 26rpx; font-weight: 600; color: $primary-dark; }
|
||||
|
||||
/* ── Skeleton ────────────────────────────── */
|
||||
.skeleton-list { padding: 0 24rpx; }
|
||||
|
||||
.skeleton-item {
|
||||
height: 80rpx;
|
||||
border-radius: 12rpx;
|
||||
margin-bottom: 16rpx;
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 400% 100%;
|
||||
animation: shimmer 1.4s infinite;
|
||||
}
|
||||
|
||||
/* ── Empty ───────────────────────────────── */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 100rpx 0;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.empty-icon { font-size: 80rpx; }
|
||||
.empty-text { font-size: 28rpx; color: #bbb; }
|
||||
|
||||
/* ── Day group ───────────────────────────── */
|
||||
.day-group { margin: 0 24rpx 24rpx; }
|
||||
|
||||
.day-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16rpx 8rpx;
|
||||
}
|
||||
|
||||
.day-label { font-size: 28rpx; font-weight: 700; color: #1a1a2e; }
|
||||
.day-count { font-size: 22rpx; color: #999; }
|
||||
|
||||
/* ── Template row ────────────────────────── */
|
||||
.tpl-row {
|
||||
background: #ffffff;
|
||||
border-radius: 12rpx;
|
||||
padding: 20rpx 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12rpx;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.06);
|
||||
|
||||
&--inactive { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.tpl-time { display: flex; flex-direction: column; gap: 6rpx; }
|
||||
.tpl-time-text { font-size: 28rpx; font-weight: 600; color: #1a1a2e; }
|
||||
.tpl-capacity { font-size: 22rpx; color: #888; }
|
||||
|
||||
.tpl-actions { display: flex; gap: 12rpx; }
|
||||
|
||||
.tpl-toggle,
|
||||
.tpl-edit,
|
||||
.tpl-delete {
|
||||
border-radius: 20rpx;
|
||||
padding: 8rpx 20rpx;
|
||||
}
|
||||
|
||||
.toggle--on { background: rgba(39,174,96,0.12); }
|
||||
.toggle--on .tpl-toggle-text { font-size: 24rpx; color: #27ae60; }
|
||||
.toggle--off { background: rgba(230,126,34,0.12); }
|
||||
.toggle--off .tpl-toggle-text { font-size: 24rpx; color: #e67e22; }
|
||||
|
||||
.tpl-edit { background: rgba(26,26,46,0.08); }
|
||||
.tpl-edit-text { font-size: 24rpx; color: #1a1a2e; }
|
||||
|
||||
.tpl-delete { background: rgba(192,57,43,0.08); }
|
||||
.tpl-delete-text { font-size: 24rpx; color: #c0392b; }
|
||||
|
||||
/* ── Save bar ────────────────────────────── */
|
||||
.save-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 20rpx 24rpx 48rpx;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 -4rpx 20rpx rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
width: 100%;
|
||||
height: 96rpx;
|
||||
border-radius: 48rpx;
|
||||
background: linear-gradient(90deg, #1a1a2e, #2d2d5e);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&--loading { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.save-btn-text { font-size: 30rpx; font-weight: 700; color: $primary-dark; }
|
||||
|
||||
/* ── Modal ───────────────────────────────── */
|
||||
.modal-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: 100%;
|
||||
background: #ffffff;
|
||||
border-radius: 24rpx 24rpx 0 0;
|
||||
padding: 40rpx 32rpx 60rpx;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: #1a1a2e;
|
||||
display: block;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.modal-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24rpx 0;
|
||||
border-bottom: 1rpx solid #f5f5f5;
|
||||
|
||||
&--last { border-bottom: none; }
|
||||
}
|
||||
|
||||
.modal-label { font-size: 26rpx; color: #555; width: 140rpx; flex-shrink: 0; }
|
||||
|
||||
.modal-input { flex: 1; text-align: right; font-size: 26rpx; color: #222; }
|
||||
|
||||
.picker-display { display: flex; align-items: center; gap: 8rpx; }
|
||||
.picker-text { font-size: 26rpx; color: #222; }
|
||||
.picker-arrow { font-size: 26rpx; color: #bbb; }
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
margin-top: 32rpx;
|
||||
}
|
||||
|
||||
.modal-cancel {
|
||||
flex: 1;
|
||||
height: 88rpx;
|
||||
background: #f0f0f0;
|
||||
border-radius: 44rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-cancel-text { font-size: 28rpx; color: #555; }
|
||||
|
||||
.modal-confirm {
|
||||
flex: 2;
|
||||
height: 88rpx;
|
||||
background: linear-gradient(90deg, #1a1a2e, #2d2d5e);
|
||||
border-radius: 44rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-confirm-text { font-size: 28rpx; font-weight: 700; color: $primary-dark; }
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<view class="booking-page">
|
||||
<view class="booking-page" :style="{ height: pageHeight }">
|
||||
<!-- ──────────── Status bar spacing ──────────── -->
|
||||
<view class="status-bar" :style="{ height: statusBarHeight }" />
|
||||
|
||||
@@ -10,15 +10,18 @@
|
||||
|
||||
<!-- ──────────── Date & period filters ──────────── -->
|
||||
<view class="filter-header">
|
||||
<DateSelector v-model="selectedDate" @select="onDateSelect" />
|
||||
<TimePeriodFilter v-model="selectedPeriod" @change="onPeriodChange" />
|
||||
<view class="calendar-heading">
|
||||
<text class="calendar-month">{{ selectedMonthLabel }}</text>
|
||||
<text class="calendar-hint">选择上课日期</text>
|
||||
</view>
|
||||
<DateSelector v-model="selectedDate" variant="soft" @select="onDateSelect" />
|
||||
<TimePeriodFilter v-model="selectedPeriod" variant="soft" @change="onPeriodChange" />
|
||||
</view>
|
||||
|
||||
<!-- ──────────── Slot list ──────────── -->
|
||||
<scroll-view
|
||||
class="slot-scroll"
|
||||
scroll-y
|
||||
:style="{ height: scrollHeight }"
|
||||
refresher-enabled
|
||||
:refresher-triggered="refreshing"
|
||||
@refresherrefresh="onRefresh"
|
||||
@@ -37,12 +40,7 @@
|
||||
|
||||
<!-- Empty state -->
|
||||
<view v-else-if="filteredSlots.length === 0" class="empty-wrap">
|
||||
<view class="empty-illustration">
|
||||
<view class="empty-circle outer" />
|
||||
<view class="empty-circle inner" />
|
||||
<view class="empty-dot" />
|
||||
</view>
|
||||
<text class="empty-text">当日暂无可约时段</text>
|
||||
<text class="empty-text">这个时段还没有课程</text>
|
||||
<text class="empty-sub">请选择其他日期或时段查看</text>
|
||||
</view>
|
||||
|
||||
@@ -50,9 +48,8 @@
|
||||
<view v-else class="slot-list">
|
||||
<!-- Date summary -->
|
||||
<view class="date-summary">
|
||||
<text class="date-summary-text">
|
||||
共 {{ filteredSlots.length }} 个可选时段
|
||||
</text>
|
||||
<text class="date-summary-title">{{ selectedDayLabel }}</text>
|
||||
<text class="date-summary-text">{{ filteredSlots.length }} 个时段</text>
|
||||
</view>
|
||||
|
||||
<SlotCard
|
||||
@@ -82,7 +79,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||
import { onResize, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||
import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pilates/shared'
|
||||
import { BookingStatus, TIME_PERIODS } from '@mp-pilates/shared'
|
||||
import { useBookingStore } from '../../stores/booking'
|
||||
@@ -126,28 +123,26 @@ onShareTimeline(() => {
|
||||
|
||||
// ─── Layout ───────────────────────────────────────────────
|
||||
const statusBarHeight = ref('20px')
|
||||
const scrollHeight = ref('500px')
|
||||
// Heights of static elements above scroll-view (in rpx, converted to px)
|
||||
const PAGE_HEADER_RPX = 88 // title bar height
|
||||
const FILTER_HEADER_RPX = 240 // DateSelector + TimePeriodFilter
|
||||
const TABBAR_RPX = 100
|
||||
const pageHeight = ref('100vh')
|
||||
|
||||
function updateLayout() {
|
||||
const { statusBarHeight: statusBarPx, windowWidth } = getSystemLayout()
|
||||
const ratio = windowWidth / 750
|
||||
statusBarHeight.value = `${statusBarPx}px`
|
||||
|
||||
const headerPx = Math.round(PAGE_HEADER_RPX * ratio)
|
||||
const filterPx = Math.round(FILTER_HEADER_RPX * ratio)
|
||||
const tabbarPx = Math.round(TABBAR_RPX * ratio)
|
||||
|
||||
// scroll-view fills remaining space: window - statusBar - pageHeader - filters - tabbar
|
||||
const { windowHeight } = uni.getWindowInfo()
|
||||
const remaining = windowHeight - statusBarPx - headerPx - filterPx - tabbarPx
|
||||
scrollHeight.value = `${remaining}px`
|
||||
statusBarHeight.value = `${getSystemLayout().statusBarHeight}px`
|
||||
// The mini-program window already excludes its native tab bar. Flex layout
|
||||
// gives the remaining height to the list as the filters change size.
|
||||
pageHeight.value = `${uni.getWindowInfo().windowHeight}px`
|
||||
}
|
||||
|
||||
updateLayout()
|
||||
onResize(updateLayout)
|
||||
|
||||
const selectedMonthLabel = computed(() => {
|
||||
const [year, month] = selectedDate.value.split('-')
|
||||
return `${year} 年 ${Number(month)} 月`
|
||||
})
|
||||
const selectedDayLabel = computed(() => {
|
||||
const [, month, day] = selectedDate.value.split('-')
|
||||
return `${Number(month)} 月 ${Number(day)} 日的课程`
|
||||
})
|
||||
|
||||
// ─── Filtered slots ───────────────────────────────────────
|
||||
const filteredSlots = computed<TimeSlotWithBookingStatus[]>(() => {
|
||||
@@ -310,213 +305,33 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.booking-page {
|
||||
height: 100vh;
|
||||
background: $primary-bg;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Status bar ───────────────────────────────────── */
|
||||
.status-bar {
|
||||
flex-shrink: 0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* ── Page header ──────────────────────────────────── */
|
||||
.page-header {
|
||||
flex-shrink: 0;
|
||||
height: 88rpx;
|
||||
display: flex;
|
||||
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;
|
||||
.booking-page { height: 100vh; background: #fbf9f6; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.status-bar { flex-shrink: 0; }
|
||||
.page-header { flex-shrink: 0; height: 88rpx; display: flex; align-items: center; justify-content: center; }
|
||||
.page-title { font-size: 34rpx; font-weight: 500; color: #514943; }
|
||||
.filter-header { flex-shrink: 0; border-bottom: 1rpx solid #eee8e3; }
|
||||
.calendar-heading { display: flex; align-items: baseline; justify-content: space-between; padding: 24rpx 32rpx 16rpx; gap: 16rpx; }
|
||||
.calendar-month { font-size: 28rpx; font-weight: 500; color: #514943; }
|
||||
.calendar-hint { font-size: 22rpx; color: #8b817b; }
|
||||
.slot-scroll { flex: 1; height: 0; min-height: 0; width: 100%; box-sizing: border-box; }
|
||||
.slot-list { display: flex; flex-direction: column; padding-top: 28rpx; }
|
||||
.date-summary { display: flex; justify-content: space-between; align-items: baseline; gap: 16rpx; padding: 0 32rpx 20rpx; }
|
||||
.date-summary-title { font-size: 25rpx; color: #6f655e; }
|
||||
.date-summary-text { font-size: 22rpx; color: #8b817b; }
|
||||
.loading-wrap { display: flex; flex-direction: column; gap: 20rpx; padding: 28rpx 32rpx; }
|
||||
.skeleton-card { height: 200rpx; box-sizing: border-box; border-radius: 28rpx; background: #fff; display: flex; align-items: center; padding: 28rpx; gap: 20rpx; }
|
||||
.skeleton-time, .skeleton-title, .skeleton-sub, .skeleton-btn {
|
||||
border-radius: 10rpx;
|
||||
background: linear-gradient(90deg, #f0eae5 25%, #faf7f3 50%, #f0eae5 75%);
|
||||
background-size: 400% 100%; animation: shimmer 1.4s infinite;
|
||||
}
|
||||
.skeleton-time { width: 90rpx; height: 70rpx; flex-shrink: 0; }
|
||||
.skeleton-body { flex: 1; display: flex; flex-direction: column; gap: 12rpx; }
|
||||
.skeleton-title { width: 80%; height: 28rpx; }
|
||||
.skeleton-sub { width: 60%; height: 20rpx; }
|
||||
.skeleton-btn { width: 110rpx; height: 60rpx; border-radius: 999rpx; flex-shrink: 0; }
|
||||
.empty-wrap { margin: 32rpx; padding: 64rpx 28rpx; border-radius: 28rpx; background: #f3efea; display: flex; flex-direction: column; align-items: center; gap: 14rpx; }
|
||||
.empty-text { font-size: 28rpx; color: #6f655e; font-weight: 400; }
|
||||
.empty-sub { font-size: 23rpx; color: #8b817b; text-align: center; line-height: 1.6; }
|
||||
.scroll-bottom-spacer { height: 28rpx; }
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,52 +1,22 @@
|
||||
<template>
|
||||
<view class="home-page">
|
||||
<!-- ① Brand Banner — fixed background layer -->
|
||||
<view class="home-page" :style="{ height: pageHeight }">
|
||||
<view class="banner-fixed">
|
||||
<BrandBanner :studio-info="studioStore.studioInfo" />
|
||||
</view>
|
||||
|
||||
<!-- Pull-to-refresh wrapper — scrollable foreground -->
|
||||
<scroll-view
|
||||
class="page-scroll"
|
||||
scroll-y
|
||||
:scroll-top="scrollTop"
|
||||
:scroll-with-animation="true"
|
||||
:refresher-enabled="true"
|
||||
:refresher-triggered="refreshing"
|
||||
@refresherrefresh="handleRefresh"
|
||||
@refresherrestore="refreshing = false"
|
||||
>
|
||||
<!-- Transparent spacer to reveal Banner behind -->
|
||||
<scroll-view class="page-scroll" scroll-y :scroll-into-view="scrollTarget"
|
||||
scroll-with-animation refresher-enabled :refresher-triggered="refreshing"
|
||||
@refresherrefresh="handleRefresh" @refresherrestore="refreshing = false">
|
||||
<view class="banner-spacer" />
|
||||
|
||||
<!-- Floating card with rounded top corners -->
|
||||
<view class="floating-card">
|
||||
<!-- Drag indicator -->
|
||||
<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) -->
|
||||
<view class="card-handle"><view class="card-handle-bar" /></view>
|
||||
<QuickEntry @scroll-to-card-shop="scrollToCardShop" />
|
||||
|
||||
<!-- ④ Upcoming Bookings -->
|
||||
<UpcomingBooking />
|
||||
|
||||
<!-- ④.5 Flash Sale Section -->
|
||||
<StudioInfo :studio-info="studioStore.studioInfo" />
|
||||
<FlashSaleSection ref="flashSaleRef" />
|
||||
|
||||
<!-- ⑤ Card Shop (vertical list) -->
|
||||
<view :id="cardShopAnchorId">
|
||||
<CardShop ref="cardShopRef" />
|
||||
</view>
|
||||
|
||||
<!-- ⑥ About (teacher + studio gallery) -->
|
||||
<AboutSection />
|
||||
|
||||
<!-- Bottom padding for tab bar -->
|
||||
<view class="bottom-padding" />
|
||||
</view>
|
||||
</scroll-view>
|
||||
@@ -55,7 +25,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
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 StudioInfo from '../../components/StudioInfo.vue'
|
||||
@@ -94,7 +64,9 @@ const refreshing = ref(false)
|
||||
const cardShopRef = ref<InstanceType<typeof CardShop> | null>(null)
|
||||
const flashSaleRef = ref<InstanceType<typeof FlashSaleSection> | null>(null)
|
||||
const cardShopAnchorId = 'card-shop-anchor'
|
||||
const scrollTop = ref(0)
|
||||
const scrollTarget = ref('')
|
||||
const pageHeight = ref(`${uni.getWindowInfo().windowHeight}px`)
|
||||
onResize(() => { pageHeight.value = `${uni.getWindowInfo().windowHeight}px` })
|
||||
const pendingScrollToCardShop = ref(false)
|
||||
|
||||
// Listen for cross-page scroll request (e.g. from booking page "去购买")
|
||||
@@ -132,8 +104,10 @@ async function refreshData() {
|
||||
await Promise.allSettled(tasks)
|
||||
|
||||
// Also refresh card shop and flash sales
|
||||
cardShopRef.value?.fetchCardTypes()
|
||||
flashSaleRef.value?.fetchFlashSales()
|
||||
await Promise.allSettled([
|
||||
cardShopRef.value?.fetchCardTypes(),
|
||||
flashSaleRef.value?.fetchFlashSales(),
|
||||
])
|
||||
}
|
||||
|
||||
async function handleRefresh() {
|
||||
@@ -145,78 +119,30 @@ async function handleRefresh() {
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToCardShop() {
|
||||
// Reset first so setting the same value still triggers scroll
|
||||
scrollTop.value = 0
|
||||
nextTick(() => {
|
||||
uni.createSelectorQuery()
|
||||
.select(`#${cardShopAnchorId}`)
|
||||
.boundingClientRect()
|
||||
.selectViewport()
|
||||
.scrollOffset((res) => {
|
||||
if (res) {
|
||||
scrollTop.value = (res as UniApp.NodeInfo).scrollTop ?? 0
|
||||
}
|
||||
})
|
||||
.exec()
|
||||
})
|
||||
async function scrollToCardShop() {
|
||||
scrollTarget.value = ''
|
||||
await nextTick()
|
||||
scrollTarget.value = cardShopAnchorId
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.home-page {
|
||||
position: relative;
|
||||
height: 100vh;
|
||||
background: #FAF8F5;
|
||||
}
|
||||
|
||||
/* Banner fixed behind everything */
|
||||
.banner-fixed {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* Scroll layer sits above banner */
|
||||
.page-scroll {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex: 1;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Transparent spacer lets banner peek through */
|
||||
.banner-spacer {
|
||||
height: 420rpx;
|
||||
}
|
||||
|
||||
/* Floating card that overlaps the banner */
|
||||
.home-page { position: relative; height: 100vh; background: #fbf9f6; }
|
||||
.banner-fixed { position: fixed; top: 0; left: 0; width: 100%; z-index: 0; }
|
||||
.page-scroll { position: relative; z-index: 1; height: 100%; }
|
||||
.banner-spacer { height: 420rpx; }
|
||||
.floating-card {
|
||||
position: relative;
|
||||
background: #FAF8F5;
|
||||
border-radius: 32rpx 32rpx 0 0;
|
||||
min-height: 100vh;
|
||||
padding-top: 12rpx;
|
||||
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
/* Small drag indicator at top of card */
|
||||
.card-handle {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 16rpx 0 8rpx;
|
||||
}
|
||||
|
||||
.card-handle-bar {
|
||||
width: 64rpx;
|
||||
height: 8rpx;
|
||||
border-radius: 4rpx;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.bottom-padding {
|
||||
height: 120rpx;
|
||||
border-radius: 32rpx 32rpx 0 0;
|
||||
border-top: 1rpx solid rgba(255, 255, 255, 0.7);
|
||||
background: linear-gradient(180deg, rgba(251, 249, 246, 0.86), #fbf9f6 260rpx);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
backdrop-filter: blur(16px);
|
||||
box-shadow: 0 -4rpx 20rpx rgba(66, 54, 45, 0.06);
|
||||
}
|
||||
.card-handle { display: flex; justify-content: center; padding: 16rpx 0 8rpx; }
|
||||
.card-handle-bar { width: 64rpx; height: 8rpx; border-radius: 4rpx; background: rgba(112, 94, 79, 0.18); }
|
||||
.bottom-padding { height: 48rpx; }
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,12 @@
|
||||
<template>
|
||||
<view class="profile-page">
|
||||
<!-- Custom nav bar (transparent, blends with UserCard gradient) -->
|
||||
<CustomNavBar title="我的" transparent />
|
||||
<view class="profile-page" :style="{ paddingTop: navBarHeight + 'px' }">
|
||||
<view class="profile-page__nav" :style="{ paddingTop: statusBarHeight + 'px' }">
|
||||
<view class="profile-page__nav-title">我的</view>
|
||||
</view>
|
||||
|
||||
<!-- User card -->
|
||||
<UserCard :logged-in="loggedIn" :has-profile="hasProfile" :user="user" :stats="stats" :memberships="memberships"
|
||||
:loading="loginLoading" :nav-bar-height="navBarHeight" @login="handleLogin" />
|
||||
:loading="loginLoading" @login="handleLogin" @edit="goToInfo" />
|
||||
|
||||
<!-- Menu section: always visible -->
|
||||
<ProfileMenu
|
||||
@@ -13,10 +14,12 @@
|
||||
:require-auth="loggedIn"
|
||||
:active-membership-count="activeMembershipCount"
|
||||
:upcoming-booking-count="upcomingBookingCount"
|
||||
:invite-share-eligible="!!user?.inviteShareEligible"
|
||||
@clear-cache="handleClearCache"
|
||||
@about="handleAbout"
|
||||
@require-login="handleLogin"
|
||||
/>
|
||||
>
|
||||
<PracticeActivityCard v-if="loggedIn" :key="userStore.token" :refresh-key="activityRefreshKey" />
|
||||
</ProfileMenu>
|
||||
|
||||
<!-- Logout button: only when logged in -->
|
||||
<view v-if="loggedIn" class="profile-page__logout-wrap">
|
||||
@@ -33,17 +36,19 @@ import { useUserStore } from '../../stores/user'
|
||||
import { useBookingStore } from '../../stores/booking'
|
||||
import { getSystemLayout } from '../../utils/system'
|
||||
import { getErrorMessage } from '../../utils/auth'
|
||||
import PracticeActivityCard from '../../components/PracticeActivityCard.vue'
|
||||
import UserCard from '../../components/UserCard.vue'
|
||||
import ProfileMenu from '../../components/ProfileMenu.vue'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const bookingStore = useBookingStore()
|
||||
const { loggedIn, hasProfile, user, stats, memberships, isAdmin } = storeToRefs(userStore)
|
||||
const { upcomingBookings } = storeToRefs(bookingStore)
|
||||
|
||||
const activityRefreshKey = ref(0)
|
||||
const loginLoading = ref(false)
|
||||
const navBarHeight = ref(64)
|
||||
const navBarHeight = ref(getSystemLayout().navBarHeight)
|
||||
const statusBarHeight = getSystemLayout().statusBarHeight
|
||||
|
||||
const activeMembershipCount = computed(
|
||||
() => user.value?.activeMembershipCount ?? userStore.activeMemberships.length,
|
||||
@@ -74,6 +79,7 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onShow(async () => {
|
||||
activityRefreshKey.value += 1
|
||||
if (loggedIn.value) {
|
||||
await Promise.all([
|
||||
userStore.fetchProfile(),
|
||||
@@ -102,12 +108,16 @@ async function handleLogin() {
|
||||
}
|
||||
}
|
||||
|
||||
function goToInfo() {
|
||||
uni.navigateTo({ url: '/pages/profile/info' })
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
uni.showModal({
|
||||
title: '退出登录',
|
||||
content: '确定要退出登录吗?',
|
||||
confirmText: '退出',
|
||||
confirmColor: '#ff4d4f',
|
||||
confirmColor: '#9c7a6e',
|
||||
success(res) {
|
||||
if (res.confirm) {
|
||||
userStore.logout()
|
||||
@@ -129,38 +139,14 @@ function handleClearCache() {
|
||||
})
|
||||
}
|
||||
|
||||
function handleAbout() {
|
||||
uni.showModal({
|
||||
title: '关于我们',
|
||||
content: 'Focus Core 普拉提工作室\n版本 1.0.0\n\n专注核心,遇见更好的自己',
|
||||
showCancel: false,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.profile-page {
|
||||
min-height: 100vh;
|
||||
|
||||
&__logout-wrap {
|
||||
margin: $spacing-xl $spacing-lg $spacing-xl;
|
||||
}
|
||||
|
||||
&__logout-btn {
|
||||
width: 100%;
|
||||
height: 88rpx;
|
||||
line-height: 88rpx;
|
||||
background: $bg-card;
|
||||
color: $error-color;
|
||||
font-size: 30rpx;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
border-radius: $radius-lg;
|
||||
text-align: center;
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
min-height: 100vh; box-sizing: border-box; background: #fbf9f6; padding-bottom: 36rpx;
|
||||
&__nav { position: fixed; top: 0; left: 0; right: 0; z-index: 101; background: #fbf9f6; }
|
||||
&__nav-title { height: 88rpx; display: flex; align-items: center; justify-content: center; font-size: 34rpx; font-weight: 500; color: #514943; }
|
||||
&__logout-wrap { margin: 28rpx 32rpx 0; }
|
||||
&__logout-btn { width: 100%; height: 80rpx; line-height: 80rpx; padding: 0; margin: 0; border: none; border-radius: 24rpx; background: #f2ede7; color: #91766a; font-size: 25rpx; font-weight: 400; &::after { border: none; } }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,112 +1,62 @@
|
||||
<template>
|
||||
<view class="info-page" :style="{ paddingTop: navBarHeight }">
|
||||
<CustomNavBar
|
||||
:title="isFromLogin ? '完善个人信息' : '个人信息'"
|
||||
:show-back="!isFromLogin"
|
||||
/>
|
||||
|
||||
<!-- First-login welcome banner -->
|
||||
<CustomNavBar :title="isFromLogin ? '完善个人信息' : '个人信息'" :show-back="!isFromLogin" />
|
||||
<view v-if="isFromLogin" class="welcome-banner">
|
||||
<view class="welcome-content">
|
||||
<view class="welcome-text">
|
||||
<text class="welcome-title">欢迎加入</text>
|
||||
<text class="welcome-desc">设置你的头像和昵称,让大家认识你</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="welcome-title">欢迎来到工作室</text>
|
||||
<text class="welcome-desc">设置头像和昵称,方便老师认识你。</text>
|
||||
</view>
|
||||
|
||||
<!-- Avatar section -->
|
||||
<view class="avatar-section" :class="{ 'avatar-section--welcome': isFromLogin }">
|
||||
<button class="avatar-btn" open-type="chooseAvatar" @chooseavatar="handleChooseAvatar">
|
||||
<view class="avatar-section">
|
||||
<button class="avatar-btn" open-type="chooseAvatar" :disabled="busy" @chooseavatar="handleChooseAvatar">
|
||||
<view class="avatar-wrap">
|
||||
<image
|
||||
v-if="displayAvatarUrl"
|
||||
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>
|
||||
<image v-if="displayAvatarUrl" class="avatar" :src="displayAvatarUrl" mode="aspectFill" @error="avatarFailed = true" />
|
||||
<view v-else class="avatar-placeholder"><text>{{ nicknameInitial }}</text></view>
|
||||
</view>
|
||||
<view class="avatar-copy">
|
||||
<text class="avatar-title">头像</text>
|
||||
<text class="avatar-action">{{ uploadingAvatar ? '正在更新…' : '点击更换' }}</text>
|
||||
</view>
|
||||
<text class="avatar-arrow">›</text>
|
||||
</button>
|
||||
<text class="avatar-name">{{ form.nickname || '未设置昵称' }}</text>
|
||||
<text class="avatar-hint">点击头像选择微信头像</text>
|
||||
<text class="avatar-hint">头像更换后自动保存</text>
|
||||
</view>
|
||||
|
||||
<!-- Form fields -->
|
||||
<view class="form-card">
|
||||
<!-- Nickname (editable) -->
|
||||
<view class="form-row">
|
||||
<text class="form-label">昵称</text>
|
||||
<input
|
||||
class="form-input"
|
||||
type="nickname"
|
||||
v-model="form.nickname"
|
||||
placeholder="点击右侧按钮获取微信昵称"
|
||||
placeholder-style="color: #ccc"
|
||||
maxlength="20"
|
||||
:disabled="saving"
|
||||
/>
|
||||
<text class="form-arrow">›</text>
|
||||
</view>
|
||||
|
||||
<!-- Phone (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 class="form-section">
|
||||
<text class="section-title">基本资料</text>
|
||||
<view class="form-card">
|
||||
<view class="nickname-field">
|
||||
<view class="field-heading">
|
||||
<text class="form-label">昵称</text>
|
||||
<text class="field-count">{{ form.nickname.length }} / 20</text>
|
||||
</view>
|
||||
<input class="form-input" type="nickname" v-model="form.nickname" placeholder="输入昵称,或选择微信昵称"
|
||||
placeholder-style="color: #a2978e" maxlength="20" :disabled="busy" :cursor-spacing="24" />
|
||||
</view>
|
||||
<view v-if="!isFromLogin" class="form-row">
|
||||
<text class="form-label">手机号</text>
|
||||
<text v-if="hasPhone" class="form-value">{{ phoneDisplay }}</text>
|
||||
<button v-else class="bind-phone-btn" open-type="getPhoneNumber" :disabled="busy" :loading="bindingPhone" @getphonenumber="handleGetPhone">绑定手机号</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Read-only info card (hide in first-login mode) -->
|
||||
<view v-if="!isFromLogin" class="info-card">
|
||||
<view class="info-row">
|
||||
<text class="info-label">注册时间</text>
|
||||
<text class="info-value">{{ joinDateDisplay }}</text>
|
||||
</view>
|
||||
<view class="info-row info-row--last">
|
||||
<text class="info-label">会员卡数量</text>
|
||||
<text class="info-value">{{ activeMembershipCount }} 张有效</text>
|
||||
</view>
|
||||
<view class="info-row"><text class="info-label">注册时间</text><text class="info-value">{{ joinDateDisplay }}</text></view>
|
||||
<view class="info-row"><text class="info-label">有效会员卡</text><text class="info-value">{{ activeMembershipCount }} 张</text></view>
|
||||
</view>
|
||||
|
||||
<!-- Save button -->
|
||||
<view class="save-wrap">
|
||||
<view
|
||||
class="save-btn"
|
||||
:class="{
|
||||
'save-btn--loading': saving,
|
||||
'save-btn--disabled': !isFromLogin && (!isDirty || saving),
|
||||
}"
|
||||
@tap="handleSave"
|
||||
>
|
||||
<text class="save-btn-text">
|
||||
{{ saving ? '保存中...' : isFromLogin ? '保存并进入' : '保存修改' }}
|
||||
</text>
|
||||
</view>
|
||||
<text v-if="isFromLogin" class="skip-text" @tap="handleSkip">稍后再说</text>
|
||||
<button class="save-btn" :disabled="saveDisabled" :loading="saving" @tap="handleSave">
|
||||
{{ saving ? '保存中…' : isFromLogin ? '保存并进入' : '保存修改' }}
|
||||
</button>
|
||||
<text v-if="!isFromLogin" class="save-hint">{{ isDirty ? '昵称修改后,请点击保存' : '修改昵称后即可保存' }}</text>
|
||||
<button v-if="isFromLogin" class="skip-btn" :disabled="busy" @tap="handleSkip">稍后再说</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
import { wxBindPhone } from '../../utils/auth'
|
||||
@@ -125,7 +75,7 @@ onLoad((query) => {
|
||||
})
|
||||
|
||||
// ─── Nav bar height ──────────────────────────────────────
|
||||
const navBarHeight = ref('64px')
|
||||
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
|
||||
|
||||
// ─── Form state ───────────────────────────────────────────
|
||||
const form = ref({
|
||||
@@ -134,6 +84,11 @@ const form = ref({
|
||||
const originalNickname = ref('')
|
||||
const saving = ref(false)
|
||||
const uploadingAvatar = ref(false)
|
||||
const bindingPhone = ref(false)
|
||||
const initializing = ref(true)
|
||||
const avatarFailed = ref(false)
|
||||
const busy = computed(() => saving.value || uploadingAvatar.value || bindingPhone.value || initializing.value)
|
||||
const saveDisabled = computed(() => busy.value || (!isFromLogin.value && !isDirty.value))
|
||||
|
||||
// ─── Computed ─────────────────────────────────────────────
|
||||
const isDirty = computed(() => form.value.nickname.trim() !== originalNickname.value)
|
||||
@@ -165,26 +120,19 @@ const activeMembershipCount = computed(
|
||||
() => userStore.user?.activeMembershipCount ?? userStore.activeMemberships.length,
|
||||
)
|
||||
|
||||
// ─── Default avatar ───────────────────────────────────────
|
||||
const defaultAvatarUrl = computed(() => {
|
||||
const nickname = form.value.nickname || 'user'
|
||||
// 使用 dicebear 生成基于昵称的随机头像
|
||||
return `https://api.dicebear.com/7.x/identicon/svg?seed=${encodeURIComponent(nickname)}&backgroundColor=c9a87c,e8c88a`
|
||||
})
|
||||
|
||||
const displayAvatarUrl = computed(() => {
|
||||
return avatarUrl.value || defaultAvatarUrl.value
|
||||
})
|
||||
// Use a local fallback when no avatar is set or the remote image fails.
|
||||
const displayAvatarUrl = computed(() => avatarFailed.value ? '' : avatarUrl.value)
|
||||
watch(avatarUrl, () => { avatarFailed.value = false })
|
||||
|
||||
// ─── Avatar upload ────────────────────────────────────────
|
||||
async function handleChooseAvatar(e: { detail: { avatarUrl: string } }) {
|
||||
const { avatarUrl } = e.detail
|
||||
if (!avatarUrl) return
|
||||
if (!avatarUrl || busy.value) return
|
||||
|
||||
uploadingAvatar.value = true
|
||||
try {
|
||||
await userStore.updateProfile({ avatarUrl })
|
||||
await userStore.fetchProfile()
|
||||
avatarFailed.value = false
|
||||
uni.showToast({ title: '头像更新成功', icon: 'success' })
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : '更新失败,请重试'
|
||||
@@ -198,32 +146,32 @@ async function handleChooseAvatar(e: { detail: { avatarUrl: string } }) {
|
||||
async function handleGetPhone(e: {
|
||||
detail: { encryptedData: string; iv: string; errMsg: string }
|
||||
}) {
|
||||
if (busy.value) return
|
||||
if (e.detail.errMsg !== 'getPhoneNumber:ok') {
|
||||
// User denied or cancelled
|
||||
return
|
||||
}
|
||||
bindingPhone.value = true
|
||||
uni.showLoading({ title: '绑定中...' })
|
||||
try {
|
||||
const updated = await wxBindPhone(e as Parameters<typeof wxBindPhone>[0])
|
||||
await wxBindPhone(e as Parameters<typeof wxBindPhone>[0])
|
||||
// Refresh store with updated profile
|
||||
await userStore.fetchProfile()
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: '手机号绑定成功', icon: 'success' })
|
||||
// Sync nickname from updated profile
|
||||
if (updated.nickname) {
|
||||
form.value = { nickname: updated.nickname }
|
||||
originalNickname.value = updated.nickname
|
||||
}
|
||||
// Keep the local nickname draft while refreshing the phone information.
|
||||
} catch (err: unknown) {
|
||||
uni.hideLoading()
|
||||
const msg = err instanceof Error ? err.message : '绑定失败,请重试'
|
||||
uni.showToast({ title: msg, icon: 'none' })
|
||||
} finally {
|
||||
bindingPhone.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Save ─────────────────────────────────────────────────
|
||||
async function handleSave() {
|
||||
if (saving.value) return
|
||||
if (busy.value) return
|
||||
|
||||
// In first-login mode, allow saving even if not dirty (user may just want to proceed)
|
||||
if (!isFromLogin.value && !isDirty.value) return
|
||||
@@ -262,6 +210,7 @@ async function handleSave() {
|
||||
|
||||
// ─── Skip (first-login only) ─────────────────────────────
|
||||
function handleSkip() {
|
||||
if (busy.value) return
|
||||
uni.showModal({
|
||||
title: '确认跳过?',
|
||||
content: '完善头像和昵称可以让教练和伙伴更容易认识你',
|
||||
@@ -278,312 +227,58 @@ function handleSkip() {
|
||||
// ─── Lifecycle ────────────────────────────────────────────
|
||||
onMounted(async () => {
|
||||
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||
if (!isFromLogin.value) {
|
||||
await userStore.fetchProfile()
|
||||
}
|
||||
if (userStore.user) {
|
||||
form.value = { nickname: userStore.user.nickname }
|
||||
originalNickname.value = userStore.user.nickname
|
||||
try {
|
||||
if (!isFromLogin.value) {
|
||||
await userStore.fetchProfile()
|
||||
}
|
||||
if (userStore.user) {
|
||||
form.value = { nickname: userStore.user.nickname }
|
||||
originalNickname.value = userStore.user.nickname
|
||||
}
|
||||
} finally {
|
||||
initializing.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.info-page {
|
||||
min-height: 100vh;
|
||||
background: $bg-page;
|
||||
}
|
||||
|
||||
/* ── Welcome banner (first-login) ───────────────────── */
|
||||
.welcome-banner {
|
||||
position: relative;
|
||||
margin: 0 $spacing-lg $spacing-md;
|
||||
padding: 36rpx 32rpx;
|
||||
background: linear-gradient(135deg, $brand-color 0%, #6b5d52 100%);
|
||||
border-radius: $radius-lg;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -40rpx;
|
||||
right: -20rpx;
|
||||
width: 180rpx;
|
||||
height: 180rpx;
|
||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.15) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.welcome-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.welcome-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6rpx;
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
letter-spacing: 2rpx;
|
||||
}
|
||||
|
||||
.welcome-desc {
|
||||
font-size: 24rpx;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Avatar section ──────────────────────────────────── */
|
||||
.avatar-section {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.info-page { min-height: 100vh; box-sizing: border-box; padding-bottom: calc(32rpx + env(safe-area-inset-bottom)); background: #fbf9f6; color: #514943; }
|
||||
.welcome-banner { margin: 28rpx 32rpx 0; }
|
||||
.welcome-title { display: block; font-size: 32rpx; font-weight: 500; }
|
||||
.welcome-desc { display: block; margin-top: 12rpx; font-size: 24rpx; color: #8b817b; line-height: 1.7; }
|
||||
.avatar-section { margin: 28rpx 32rpx 0; padding: 28rpx; border-radius: 28rpx; background: #f2e9e3; }
|
||||
.avatar-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-wrap {
|
||||
position: relative;
|
||||
width: 160rpx;
|
||||
height: 160rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 160rpx;
|
||||
height: 160rpx;
|
||||
border-radius: 50%;
|
||||
border: 4rpx solid $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;
|
||||
}
|
||||
display: flex; align-items: center; gap: 24rpx; width: 100%; min-width: 0;
|
||||
height: auto; margin: 0; padding: 0; border: none; background: transparent;
|
||||
border-radius: 0; line-height: 1.4; text-align: left; color: #514943;
|
||||
&::after { border: none; }
|
||||
&[disabled] { background: transparent; color: #8b817b; }
|
||||
}
|
||||
.avatar-wrap { width: 120rpx; height: 120rpx; flex-shrink: 0; border-radius: 50%; overflow: hidden; box-sizing: border-box; border: 5rpx solid #fcf8f4; }
|
||||
.avatar, .avatar-placeholder { display: block; width: 100%; height: 100%; }
|
||||
.avatar-placeholder { display: flex; align-items: center; justify-content: center; background: #e0cec4; color: #7b6254; font-size: 44rpx; font-weight: 400; line-height: 1; }
|
||||
.avatar-copy { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 12rpx; }
|
||||
.avatar-title { font-size: 30rpx; font-weight: 500; }
|
||||
.avatar-action { font-size: 24rpx; color: #8b7160; }
|
||||
.avatar-arrow { flex-shrink: 0; font-size: 32rpx; color: #a38d7d; }
|
||||
.avatar-hint { display: block; margin-top: 20rpx; font-size: 21rpx; color: #9a8577; }
|
||||
.form-section { margin: 32rpx 32rpx 0; }
|
||||
.section-title { display: block; margin-bottom: 18rpx; font-size: 28rpx; font-weight: 500; }
|
||||
.form-card { padding: 0 28rpx; background: #fff; border: 1rpx solid #eee8e3; border-radius: 28rpx; }
|
||||
.nickname-field { padding: 26rpx 0 20rpx; }
|
||||
.field-heading { display: flex; align-items: center; justify-content: space-between; gap: 20rpx; }
|
||||
.form-label { flex-shrink: 0; font-size: 26rpx; color: #786b61; }
|
||||
.field-count { font-size: 20rpx; color: #a2978e; }
|
||||
.form-input { display: block; box-sizing: border-box; width: 100%; min-width: 0; height: 76rpx; margin-top: 12rpx; padding: 0 18rpx; border-radius: 14rpx; background: #faf8f5; font-size: 27rpx; color: #514943; line-height: normal; text-align: left; }
|
||||
.form-row { display: flex; align-items: center; gap: 24rpx; min-height: 104rpx; border-top: 1rpx solid #f0ebe6; }
|
||||
.form-value { flex: 1; min-width: 0; text-align: right; font-size: 26rpx; color: #6f655e; }
|
||||
.bind-phone-btn { margin: 0 0 0 auto; padding: 0 20rpx; height: 60rpx; line-height: 60rpx; flex-shrink: 0; border: none; border-radius: 999rpx; background: #edf2ec; color: #617d73; font-size: 24rpx; &::after { border: none; } }
|
||||
.info-card { margin: 24rpx 32rpx 0; padding: 8rpx 28rpx; border-radius: 26rpx; background: #f3f0eb; }
|
||||
.info-row { display: flex; align-items: baseline; justify-content: space-between; gap: 20rpx; padding: 18rpx 0; }
|
||||
.info-label { flex-shrink: 0; font-size: 23rpx; color: #8b817b; }
|
||||
.info-value { font-size: 23rpx; color: #786b61; text-align: right; }
|
||||
.save-wrap { margin: 32rpx 32rpx 0; }
|
||||
.save-btn { display: block; width: 100%; height: 88rpx; line-height: 88rpx; margin: 0; padding: 0; border: none; border-radius: 999rpx; background: #6b8276; font-size: 28rpx; font-weight: 400; color: #fff; &::after { border: none; } &[disabled] { background: #e3e8df; color: #8a9586; } }
|
||||
.save-hint { display: block; margin-top: 18rpx; font-size: 21rpx; color: #9a9086; text-align: center; }
|
||||
.skip-btn { text-align: center; margin: 16rpx auto 0; padding: 8rpx 24rpx; width: auto; border: none; background: transparent; font-size: 24rpx; color: #8b817b; &::after { border: none; } }
|
||||
</style>
|
||||
|
||||
504
packages/app/src/pages/profile/invite.vue
Normal file
504
packages/app/src/pages/profile/invite.vue
Normal file
@@ -0,0 +1,504 @@
|
||||
<template>
|
||||
<view class="invite-page" :style="{ paddingTop: navBarHeight }">
|
||||
<CustomNavBar title="邀请好友" show-back />
|
||||
|
||||
<scroll-view class="invite-scroll" scroll-y>
|
||||
<view class="hero-card">
|
||||
<view class="hero-glow hero-glow--one" />
|
||||
<view class="hero-glow hero-glow--two" />
|
||||
<text class="hero-badge">会员专享裂变活动</text>
|
||||
<text class="hero-title">邀 3 位好友体验并核销</text>
|
||||
<text class="hero-subtitle">好友购买体验课并完成上课后,会员卡立即奖励 1 节正课次数。</text>
|
||||
|
||||
<view class="hero-stats">
|
||||
<view class="hero-stat">
|
||||
<text class="hero-stat-value">{{ summary?.qualifiedInviteCount ?? 0 }}</text>
|
||||
<text class="hero-stat-label">已完成邀请</text>
|
||||
</view>
|
||||
<view class="hero-stat hero-stat--accent">
|
||||
<text class="hero-stat-value">{{ summary?.rewardedTimes ?? 0 }}</text>
|
||||
<text class="hero-stat-label">已得奖励</text>
|
||||
</view>
|
||||
<view class="hero-stat">
|
||||
<text class="hero-stat-value">{{ summary?.nextRewardRemainingCount ?? 3 }}</text>
|
||||
<text class="hero-stat-label">距下次奖励</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="progress-shell">
|
||||
<view class="progress-track">
|
||||
<view class="progress-fill" :style="{ width: progressWidth }" />
|
||||
</view>
|
||||
<text class="progress-caption">本轮进度 {{ summary?.currentCycleQualifiedCount ?? 0 }}/{{ summary?.rewardRuleInvitesRequired ?? 3 }}</text>
|
||||
</view>
|
||||
|
||||
<button class="share-btn" open-type="share">
|
||||
立即邀请好友
|
||||
</button>
|
||||
<text class="share-hint">分享后,新用户登录并购买体验课即可自动绑定邀请关系。</text>
|
||||
</view>
|
||||
|
||||
<view class="steps-card">
|
||||
<text class="section-title">活动规则</text>
|
||||
<view v-for="item in ruleSteps" :key="item.title" class="step-item">
|
||||
<view class="step-index">{{ item.index }}</view>
|
||||
<view class="step-body">
|
||||
<text class="step-title">{{ item.title }}</text>
|
||||
<text class="step-desc">{{ item.desc }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="referrals-card">
|
||||
<view class="section-head">
|
||||
<text class="section-title">邀请进度</text>
|
||||
<text class="section-meta">待完成 {{ summary?.pendingInviteCount ?? 0 }} 人</text>
|
||||
</view>
|
||||
|
||||
<view v-if="summary?.referrals?.length" class="referral-list">
|
||||
<view v-for="item in summary.referrals" :key="item.id" class="referral-item">
|
||||
<image v-if="item.inviteeAvatarUrl" class="referral-avatar" :src="item.inviteeAvatarUrl" mode="aspectFill" />
|
||||
<view v-else class="referral-avatar referral-avatar--placeholder">友</view>
|
||||
<view class="referral-main">
|
||||
<text class="referral-name">{{ item.inviteeNickname || '新好友' }}</text>
|
||||
<text class="referral-time">邀请于 {{ formatDateTime(item.invitedAt) }}</text>
|
||||
</view>
|
||||
<text class="referral-status" :class="statusClass(item.status)">{{ statusLabel(item.status) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="empty-block">
|
||||
<text class="empty-title">还没有邀请记录</text>
|
||||
<text class="empty-desc">先分享给 3 位好友,完成一次体验闭环就会在这里点亮进度。</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="reward-card">
|
||||
<view class="section-head">
|
||||
<text class="section-title">奖励记录</text>
|
||||
<text class="section-meta">累计 {{ summary?.rewardedTimes ?? 0 }} 节</text>
|
||||
</view>
|
||||
<view v-if="summary?.rewardGrants?.length" class="reward-list">
|
||||
<view v-for="item in summary.rewardGrants" :key="item.id" class="reward-item">
|
||||
<text class="reward-item-title">完成 {{ item.qualifiedReferralCount }} 位好友核销</text>
|
||||
<text class="reward-item-time">{{ formatDateTime(item.grantedAt) }}</text>
|
||||
<text class="reward-item-tag">+{{ item.rewardTimes }} 节</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="empty-block empty-block--warm">
|
||||
<text class="empty-title">还未获得奖励</text>
|
||||
<text class="empty-desc">每 3 位好友完成体验核销,系统自动增加 1 节真实会员课次。</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="bottom-space" />
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||
import { InviteReferralStatus } from '@mp-pilates/shared'
|
||||
import { useInviteStore } from '../../stores/invite'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
import { getSystemLayout } from '../../utils/system'
|
||||
import { formatDateTime } from '../../utils/format'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
|
||||
const inviteStore = useInviteStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const navBarHeight = ref('64px')
|
||||
|
||||
const summary = computed(() => inviteStore.activity)
|
||||
const progressWidth = computed(() => {
|
||||
const current = summary.value?.currentCycleQualifiedCount ?? 0
|
||||
const total = summary.value?.rewardRuleInvitesRequired ?? 3
|
||||
return `${Math.min(100, (current / total) * 100)}%`
|
||||
})
|
||||
|
||||
const ruleSteps = [
|
||||
{ index: '01', title: '分享活动页', desc: '会员用户把活动页转发给微信好友或朋友圈。' },
|
||||
{ index: '02', title: '好友购买体验课', desc: '新好友通过你的分享进入,并成功购买体验课。' },
|
||||
{ index: '03', title: '体验课完成核销', desc: '好友到店体验并被老师核销后,这次邀请记为有效。' },
|
||||
{ index: '04', title: '满 3 人自动加课', desc: '每累计 3 位有效邀请,系统自动给你的会员卡增加 1 节。' },
|
||||
]
|
||||
|
||||
onLoad((query) => {
|
||||
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||
const inviterId = typeof query?.inviterId === 'string' ? query.inviterId : ''
|
||||
if (inviterId) {
|
||||
uni.setStorageSync('invite_inviter_id', inviterId)
|
||||
}
|
||||
})
|
||||
|
||||
onShow(async () => {
|
||||
if (!userStore.loggedIn) {
|
||||
return
|
||||
}
|
||||
await Promise.all([
|
||||
userStore.fetchProfile(),
|
||||
inviteStore.fetchActivity(),
|
||||
])
|
||||
})
|
||||
|
||||
onShareAppMessage(() => ({
|
||||
title: '邀 3 位好友体验核销,立得 1 节会员正课',
|
||||
path: summary.value?.sharePath || `/pages/profile/invite?inviterId=${userStore.user?.id || ''}`,
|
||||
imageUrl: '',
|
||||
}))
|
||||
|
||||
onShareTimeline(() => ({
|
||||
title: '邀 3 位好友体验核销,立得 1 节会员正课',
|
||||
query: `inviterId=${userStore.user?.id || ''}`,
|
||||
}))
|
||||
|
||||
function statusLabel(status: InviteReferralStatus): string {
|
||||
const map: Record<InviteReferralStatus, string> = {
|
||||
[InviteReferralStatus.REGISTERED]: '已注册',
|
||||
[InviteReferralStatus.TRIAL_PURCHASED]: '已购体验课',
|
||||
[InviteReferralStatus.QUALIFIED]: '已完成核销',
|
||||
}
|
||||
return map[status]
|
||||
}
|
||||
|
||||
function statusClass(status: InviteReferralStatus): string {
|
||||
if (status === InviteReferralStatus.QUALIFIED) return 'referral-status--done'
|
||||
if (status === InviteReferralStatus.TRIAL_PURCHASED) return 'referral-status--paid'
|
||||
return 'referral-status--registered'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.invite-page {
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(255, 142, 83, 0.28), transparent 34%),
|
||||
radial-gradient(circle at top right, rgba(255, 214, 102, 0.34), transparent 26%),
|
||||
linear-gradient(180deg, #fff5db 0%, #ffe7ea 30%, #fef7ff 100%);
|
||||
}
|
||||
|
||||
.invite-scroll {
|
||||
height: 100vh;
|
||||
padding: 24rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.hero-card,
|
||||
.steps-card,
|
||||
.referrals-card,
|
||||
.reward-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 36rpx;
|
||||
padding: 32rpx;
|
||||
margin-bottom: 24rpx;
|
||||
box-shadow: 0 18rpx 50rpx rgba(157, 70, 42, 0.08);
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
background: linear-gradient(135deg, #ff7a45 0%, #ff4d6d 48%, #ffb347 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.hero-glow {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
opacity: 0.28;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.hero-glow--one {
|
||||
width: 260rpx;
|
||||
height: 260rpx;
|
||||
top: -90rpx;
|
||||
right: -40rpx;
|
||||
}
|
||||
|
||||
.hero-glow--two {
|
||||
width: 180rpx;
|
||||
height: 180rpx;
|
||||
bottom: -50rpx;
|
||||
left: -40rpx;
|
||||
}
|
||||
|
||||
.hero-badge {
|
||||
display: inline-flex;
|
||||
align-self: flex-start;
|
||||
padding: 10rpx 18rpx;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
border-radius: 999rpx;
|
||||
font-size: 22rpx;
|
||||
margin-bottom: 18rpx;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
display: block;
|
||||
font-size: 52rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.18;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
display: block;
|
||||
margin-top: 18rpx;
|
||||
font-size: 26rpx;
|
||||
line-height: 1.7;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 18rpx;
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
|
||||
.hero-stat {
|
||||
padding: 24rpx 18rpx;
|
||||
border-radius: 26rpx;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
backdrop-filter: blur(10rpx);
|
||||
}
|
||||
|
||||
.hero-stat--accent {
|
||||
background: rgba(75, 16, 16, 0.22);
|
||||
}
|
||||
|
||||
.hero-stat-value {
|
||||
display: block;
|
||||
font-size: 46rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hero-stat-label {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 22rpx;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
}
|
||||
|
||||
.progress-shell {
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
height: 20rpx;
|
||||
border-radius: 999rpx;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #fff7ad 0%, #ffffff 100%);
|
||||
}
|
||||
|
||||
.progress-caption {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
font-size: 22rpx;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
.share-btn {
|
||||
margin-top: 28rpx;
|
||||
height: 96rpx;
|
||||
line-height: 96rpx;
|
||||
border-radius: 999rpx;
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: #ff5a3c;
|
||||
background: linear-gradient(90deg, #fff7e4 0%, #ffffff 100%);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.share-btn::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.share-hint {
|
||||
display: block;
|
||||
margin-top: 14rpx;
|
||||
font-size: 22rpx;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
}
|
||||
|
||||
.steps-card {
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(255, 247, 234, 0.92));
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: block;
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: #30201a;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 22rpx;
|
||||
}
|
||||
|
||||
.section-meta {
|
||||
font-size: 22rpx;
|
||||
color: #9b6b55;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
display: flex;
|
||||
gap: 18rpx;
|
||||
align-items: flex-start;
|
||||
padding: 22rpx 0;
|
||||
border-bottom: 1rpx solid rgba(214, 171, 134, 0.2);
|
||||
}
|
||||
|
||||
.step-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.step-index {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 20rpx;
|
||||
text-align: center;
|
||||
line-height: 64rpx;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #ff8f5a 0%, #ff4d6d 100%);
|
||||
}
|
||||
|
||||
.step-body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #36231d;
|
||||
}
|
||||
|
||||
.step-desc {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.7;
|
||||
color: #7b5d52;
|
||||
}
|
||||
|
||||
.referrals-card {
|
||||
background: linear-gradient(180deg, #ffffff 0%, #fff7fb 100%);
|
||||
}
|
||||
|
||||
.reward-card {
|
||||
background: linear-gradient(180deg, #fffdf5 0%, #fff2dc 100%);
|
||||
}
|
||||
|
||||
.referral-item,
|
||||
.reward-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 22rpx 0;
|
||||
border-bottom: 1rpx solid rgba(221, 196, 177, 0.35);
|
||||
}
|
||||
|
||||
.referral-item:last-child,
|
||||
.reward-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.referral-avatar {
|
||||
width: 78rpx;
|
||||
height: 78rpx;
|
||||
border-radius: 50%;
|
||||
margin-right: 18rpx;
|
||||
background: #ffd9c8;
|
||||
}
|
||||
|
||||
.referral-avatar--placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: #ff6f3c;
|
||||
}
|
||||
|
||||
.referral-main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.referral-name,
|
||||
.reward-item-title {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #31211a;
|
||||
}
|
||||
|
||||
.referral-time,
|
||||
.reward-item-time {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 22rpx;
|
||||
color: #8b6d62;
|
||||
}
|
||||
|
||||
.referral-status,
|
||||
.reward-item-tag {
|
||||
padding: 12rpx 18rpx;
|
||||
border-radius: 999rpx;
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.referral-status--registered {
|
||||
color: #9c5e2f;
|
||||
background: #fff0de;
|
||||
}
|
||||
|
||||
.referral-status--paid {
|
||||
color: #c44f1f;
|
||||
background: #ffe0d1;
|
||||
}
|
||||
|
||||
.referral-status--done,
|
||||
.reward-item-tag {
|
||||
color: #0f7a53;
|
||||
background: #dff7ea;
|
||||
}
|
||||
|
||||
.empty-block {
|
||||
padding: 36rpx 0 10rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-block--warm {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #5f4337;
|
||||
}
|
||||
|
||||
.empty-desc {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.8;
|
||||
color: #9c7d70;
|
||||
}
|
||||
|
||||
.bottom-space {
|
||||
height: 48rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,164 +1,107 @@
|
||||
<template>
|
||||
<view class="membership-page" :style="{ paddingTop: navBarHeight }">
|
||||
<view class="membership-page" :style="{ paddingTop: navBarHeight, height: pageHeight }">
|
||||
<CustomNavBar title="我的会员卡" show-back />
|
||||
<scroll-view
|
||||
class="scroll"
|
||||
scroll-y
|
||||
refresher-enabled
|
||||
:refresher-triggered="refreshing"
|
||||
@refresherrefresh="onRefresh"
|
||||
>
|
||||
<!-- Loading skeleton -->
|
||||
<view v-if="loading && !refreshing" class="loading-wrap">
|
||||
<scroll-view class="scroll" scroll-y refresher-enabled :refresher-triggered="refreshing" @refresherrefresh="onRefresh">
|
||||
<view v-if="loading && !refreshing && !allMemberships.length" class="loading-wrap">
|
||||
<view v-for="i in 2" :key="i" class="skeleton-card" />
|
||||
</view>
|
||||
|
||||
<!-- Empty state -->
|
||||
<view v-else-if="allMemberships.length === 0" class="empty-wrap">
|
||||
<view class="empty-card">
|
||||
<view class="empty-deco empty-deco--1" />
|
||||
<view class="empty-deco empty-deco--2" />
|
||||
<text class="empty-title">还没有会员卡</text>
|
||||
<text class="empty-sub">购买会员卡后即可预约课程</text>
|
||||
<view class="empty-btn" @tap="goStore">
|
||||
<text class="empty-btn-text">去选购</text>
|
||||
</view>
|
||||
<text class="empty-sub">选一张适合自己的卡,开始第一次练习。</text>
|
||||
<button class="empty-btn" @tap="goStore">选购会员卡</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Membership list -->
|
||||
<view v-else class="list">
|
||||
<!-- Active cards -->
|
||||
<view v-if="activeMemberships.length > 0" class="group-section">
|
||||
<view v-if="activeMemberships.length" class="group-section">
|
||||
<view class="group-header">
|
||||
<text class="group-title">有效会员卡</text>
|
||||
<text class="group-count">{{ activeMemberships.length }} 张</text>
|
||||
<text class="group-title">正在使用</text>
|
||||
<text class="group-count">{{ activeMemberships.length }} 张有效卡</text>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-for="m in activeMemberships"
|
||||
:key="m.id"
|
||||
class="mc"
|
||||
:class="cardBgClass(m.cardType.type)"
|
||||
>
|
||||
<!-- Decorative circles -->
|
||||
<view class="mc-deco mc-deco--1" />
|
||||
<view class="mc-deco mc-deco--2" />
|
||||
|
||||
<!-- Top row: name + status -->
|
||||
<view v-for="m in activeMemberships" :key="m.id" class="mc" :class="cardBgClass(m.cardType.type)">
|
||||
<view class="mc-top">
|
||||
<view class="mc-name-area">
|
||||
<text class="mc-name">{{ m.cardType.name }}</text>
|
||||
<view class="mc-type-tag">
|
||||
<text class="mc-type-text">{{ getCardTypeLabel(m.cardType.type) }}</text>
|
||||
</view>
|
||||
<text class="mc-type-text">{{ getCardTypeLabel(m.cardType.type) }}</text>
|
||||
</view>
|
||||
<view class="mc-status mc-status--active">
|
||||
<view class="mc-status-dot" />
|
||||
<text class="mc-status-text">有效</text>
|
||||
<text class="mc-status mc-status--active">有效</text>
|
||||
</view>
|
||||
<view class="mc-balance">
|
||||
<view class="mc-number-row">
|
||||
<text class="mc-big-num">{{ m.remainingTimes !== null ? m.remainingTimes : daysRemaining(m) }}</text>
|
||||
<text class="mc-big-unit">{{ m.remainingTimes !== null ? '次可用' : '天剩余' }}</text>
|
||||
</view>
|
||||
<text v-if="m.remainingTimes === null" class="mc-duration-note">有效期内不限次数</text>
|
||||
<view v-else-if="getMembershipTotalTimes(m)" class="mc-progress">
|
||||
<view class="mc-progress-track"><view class="mc-progress-fill" :style="{ width: getMembershipProgressWidth(m) }" /></view>
|
||||
<text class="mc-progress-label">已用 {{ getMembershipUsedTimes(m) }} / {{ getMembershipTotalTimes(m) }} 次</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Center: highlight number (times card) -->
|
||||
<view v-if="m.remainingTimes !== null" class="mc-center">
|
||||
<text class="mc-big-num">{{ m.remainingTimes }}</text>
|
||||
<text class="mc-big-unit">次剩余</text>
|
||||
<view v-if="m.cardType.totalTimes" class="mc-progress">
|
||||
<view class="mc-progress-track">
|
||||
<view
|
||||
class="mc-progress-fill"
|
||||
:style="{ width: getMembershipProgressWidth(m) }"
|
||||
/>
|
||||
</view>
|
||||
<text class="mc-progress-label">
|
||||
已用 {{ getMembershipUsedTimes(m) }},共 {{ m.cardType.totalTimes }} 次
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Center: duration card (no times) -->
|
||||
<view v-else class="mc-center">
|
||||
<text class="mc-big-num">{{ daysRemaining(m) }}</text>
|
||||
<text class="mc-big-unit">天剩余</text>
|
||||
</view>
|
||||
|
||||
<!-- Bottom: dates -->
|
||||
<view class="mc-bottom">
|
||||
<view class="mc-date-item">
|
||||
<text class="mc-date-label">开始</text>
|
||||
<text class="mc-date-label">开始日期</text>
|
||||
<text class="mc-date-value">{{ m.startDate.slice(0, 10) }}</text>
|
||||
</view>
|
||||
<view class="mc-date-sep" />
|
||||
<view class="mc-date-item">
|
||||
<text class="mc-date-label">到期</text>
|
||||
<text class="mc-date-value">{{ m.expireDate.slice(0, 10) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Expired / used up cards -->
|
||||
<view v-if="inactiveMemberships.length > 0" class="group-section">
|
||||
<view class="group-header">
|
||||
<text class="group-title">历史记录</text>
|
||||
<text class="group-count">{{ inactiveMemberships.length }} 张</text>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-for="m in inactiveMemberships"
|
||||
:key="m.id"
|
||||
class="mc mc--inactive"
|
||||
>
|
||||
<view class="mc-deco mc-deco--1" />
|
||||
|
||||
<view class="mc-top">
|
||||
<view class="mc-name-area">
|
||||
<text class="mc-name">{{ m.cardType.name }}</text>
|
||||
<view class="mc-type-tag">
|
||||
<text class="mc-type-text">{{ getCardTypeLabel(m.cardType.type) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="mc-status" :class="inactiveStatusClass(m.status)">
|
||||
<text class="mc-status-text">{{ statusLabel(m.status) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="mc-inactive-info">
|
||||
<view v-if="m.remainingTimes !== null" class="mc-date-item">
|
||||
<text class="mc-date-label">剩余</text>
|
||||
<text class="mc-date-value">{{ m.remainingTimes }} 次</text>
|
||||
</view>
|
||||
<view class="mc-date-item">
|
||||
<view class="mc-date-item mc-date-item--end">
|
||||
<text class="mc-date-label">有效期至</text>
|
||||
<text class="mc-date-value">{{ m.expireDate.slice(0, 10) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="mc-actions">
|
||||
<button v-if="canRenewMembership(m)" class="mc-renew" @tap="goRenew(m)">续卡</button>
|
||||
<button class="mc-book" @tap="goBooking">预约课程</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="inactiveMemberships.length" class="group-section">
|
||||
<view class="group-header">
|
||||
<text class="group-title">历史记录</text>
|
||||
<text class="group-count">{{ inactiveMemberships.length }} 张</text>
|
||||
</view>
|
||||
<view v-for="m in inactiveMemberships" :key="m.id" class="mc mc--inactive">
|
||||
<view class="mc-top">
|
||||
<view class="mc-name-area">
|
||||
<text class="mc-name">{{ m.cardType.name }}</text>
|
||||
<text class="mc-type-text">{{ getCardTypeLabel(m.cardType.type) }}</text>
|
||||
</view>
|
||||
<text class="mc-status" :class="inactiveStatusClass(m.status)">{{ statusLabel(m.status) }}</text>
|
||||
</view>
|
||||
<view class="mc-history-bottom">
|
||||
<view class="mc-history-info">
|
||||
<text v-if="m.remainingTimes !== null" class="mc-date-label">剩余 {{ m.remainingTimes }} 次</text>
|
||||
<text class="mc-date-label">{{ m.expireDate.slice(0, 10) }} 到期</text>
|
||||
</view>
|
||||
<button v-if="canRenewMembership(m)" class="mc-renew mc-renew--inactive" @tap="goRenew(m)">续同款</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="scroll-bottom-spacer" />
|
||||
</scroll-view>
|
||||
|
||||
<!-- Buy more FAB -->
|
||||
<view class="fab" @tap="goStore">
|
||||
<text class="fab-text">+ 购买会员卡</text>
|
||||
<view v-if="allMemberships.length" class="purchase-dock">
|
||||
<button class="purchase-btn" @tap="goStore">选购会员卡</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { onShow, onResize } from '@dcloudio/uni-app'
|
||||
import type { MembershipWithCardType } from '@mp-pilates/shared'
|
||||
import { MembershipStatus, CardTypeCategory } from '@mp-pilates/shared'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
import { getSystemLayout } from '../../utils/system'
|
||||
import { getCardTypeLabel, getMembershipProgressWidth, getMembershipUsedTimes } from '../../utils/format'
|
||||
import { getCardTypeLabel, getMembershipProgressWidth, getMembershipUsedTimes, getMembershipTotalTimes } from '../../utils/format'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const navBarHeight = ref('64px')
|
||||
const navBarHeight = `${getSystemLayout().navBarHeight}px`
|
||||
const pageHeight = ref(`${uni.getWindowInfo().windowHeight}px`)
|
||||
onResize(() => { pageHeight.value = `${uni.getWindowInfo().windowHeight}px` })
|
||||
const loading = ref(false)
|
||||
const refreshing = ref(false)
|
||||
|
||||
@@ -210,390 +153,80 @@ async function loadMemberships() {
|
||||
|
||||
async function onRefresh() {
|
||||
refreshing.value = true
|
||||
await userStore.fetchMemberships()
|
||||
refreshing.value = false
|
||||
try {
|
||||
await userStore.fetchMemberships()
|
||||
} finally {
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goBooking() {
|
||||
uni.switchTab({ url: '/pages/booking/index' })
|
||||
}
|
||||
|
||||
function goStore() {
|
||||
uni.$emit('scrollToCardShop')
|
||||
uni.switchTab({ url: '/pages/home/index' })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||
loadMemberships()
|
||||
})
|
||||
function canRenewMembership(m: MembershipWithCardType): boolean {
|
||||
return m.cardType.type !== CardTypeCategory.TRIAL
|
||||
}
|
||||
|
||||
function goRenew(m: MembershipWithCardType) {
|
||||
uni.navigateTo({ url: `/pages/card/detail?id=${m.cardTypeId}` })
|
||||
}
|
||||
|
||||
onShow(loadMemberships)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.membership-page {
|
||||
min-height: 100vh;
|
||||
background: $bg-page;
|
||||
}
|
||||
|
||||
.scroll {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* ── Loading ─────────────────────────────── */
|
||||
.loading-wrap {
|
||||
padding: 24rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
height: 320rpx;
|
||||
border-radius: 24rpx;
|
||||
background: linear-gradient(90deg, #f0ece8 25%, #e8e4df 50%, #f0ece8 75%);
|
||||
background-size: 400% 100%;
|
||||
animation: shimmer 1.4s infinite;
|
||||
}
|
||||
|
||||
/* ── Empty ────────────────────────────────── */
|
||||
.empty-wrap {
|
||||
padding: 80rpx 24rpx;
|
||||
}
|
||||
|
||||
.empty-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, #E8D5C4, #D8C8DC);
|
||||
border-radius: 24rpx;
|
||||
padding: 64rpx 40rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.empty-deco {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
|
||||
&--1 {
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
top: -60rpx;
|
||||
right: -40rpx;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
&--2 {
|
||||
width: 140rpx;
|
||||
height: 140rpx;
|
||||
bottom: -40rpx;
|
||||
left: -20rpx;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
color: $brand-color;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.empty-sub {
|
||||
font-size: 26rpx;
|
||||
color: $text-secondary;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.empty-btn {
|
||||
margin-top: 16rpx;
|
||||
padding: 20rpx 56rpx;
|
||||
border-radius: 40rpx;
|
||||
background: rgba(74, 64, 53, 0.12);
|
||||
z-index: 1;
|
||||
|
||||
&:active { background: rgba(74, 64, 53, 0.18); }
|
||||
}
|
||||
|
||||
.empty-btn-text {
|
||||
font-size: 28rpx;
|
||||
color: $brand-color;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── List ─────────────────────────────────── */
|
||||
.list {
|
||||
padding: 16rpx 24rpx 0;
|
||||
}
|
||||
|
||||
/* ── Group ────────────────────────────────── */
|
||||
.group-section {
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12rpx 8rpx 16rpx;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
font-size: 28rpx;
|
||||
color: $text-primary;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.group-count {
|
||||
font-size: 22rpx;
|
||||
color: $text-hint;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════
|
||||
MEMBERSHIP CARD (mc)
|
||||
══════════════════════════════════════════════ */
|
||||
.mc {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 24rpx;
|
||||
padding: 28rpx 32rpx;
|
||||
margin-bottom: 20rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
/* Card type backgrounds */
|
||||
.mc--times {
|
||||
background: linear-gradient(135deg, #EDE0D4 0%, #E2D2C2 100%);
|
||||
box-shadow: 0 4rpx 20rpx rgba(212, 191, 168, 0.3);
|
||||
}
|
||||
|
||||
.mc--duration {
|
||||
background: linear-gradient(135deg, #E0D4E4 0%, #D4C6DA 100%);
|
||||
box-shadow: 0 4rpx 20rpx rgba(196, 174, 203, 0.3);
|
||||
}
|
||||
|
||||
.mc--trial {
|
||||
background: linear-gradient(135deg, #D4E2DC 0%, #C6D8D0 100%);
|
||||
box-shadow: 0 4rpx 20rpx rgba(169, 196, 188, 0.3);
|
||||
}
|
||||
|
||||
.mc--inactive {
|
||||
background: linear-gradient(135deg, #E8E4E0, #DDD9D5);
|
||||
box-shadow: 0 2rpx 12rpx rgba(180, 160, 130, 0.12);
|
||||
opacity: 0.75;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
/* Decorative circles */
|
||||
.mc-deco {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
|
||||
&--1 {
|
||||
width: 180rpx;
|
||||
height: 180rpx;
|
||||
top: -50rpx;
|
||||
right: -30rpx;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
&--2 {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
bottom: -30rpx;
|
||||
left: 40rpx;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Top row ──────────────────────────────── */
|
||||
.mc-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.mc-name-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.mc-name {
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
color: #2C2420;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.mc-type-tag {
|
||||
align-self: flex-start;
|
||||
padding: 4rpx 14rpx;
|
||||
border-radius: 10rpx;
|
||||
background: rgba(44, 36, 32, 0.1);
|
||||
}
|
||||
|
||||
.mc-type-text {
|
||||
font-size: 20rpx;
|
||||
color: rgba(44, 36, 32, 0.6);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Status */
|
||||
.mc-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
padding: 6rpx 16rpx;
|
||||
border-radius: 16rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mc-status--active {
|
||||
background: rgba(122, 158, 126, 0.18);
|
||||
}
|
||||
|
||||
.mc-status--expired,
|
||||
.mc-status--used {
|
||||
background: rgba(74, 64, 53, 0.08);
|
||||
}
|
||||
|
||||
.mc-status-dot {
|
||||
width: 10rpx;
|
||||
height: 10rpx;
|
||||
border-radius: 50%;
|
||||
background: $success-color;
|
||||
}
|
||||
|
||||
.mc-status-text {
|
||||
font-size: 22rpx;
|
||||
color: #2C2420;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Center: big number ───────────────────── */
|
||||
.mc-center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 8rpx 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.mc-big-num {
|
||||
font-size: 80rpx;
|
||||
font-weight: 800;
|
||||
color: #2C2420;
|
||||
line-height: 1;
|
||||
font-family: 'DIN Alternate', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
.mc-big-unit {
|
||||
font-size: 24rpx;
|
||||
color: rgba(44, 36, 32, 0.55);
|
||||
font-weight: 500;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
/* Progress */
|
||||
.mc-progress {
|
||||
width: 100%;
|
||||
max-width: 400rpx;
|
||||
margin-top: 20rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.mc-progress-track {
|
||||
height: 10rpx;
|
||||
background: rgba(44, 36, 32, 0.1);
|
||||
border-radius: 5rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mc-progress-fill {
|
||||
height: 100%;
|
||||
background: rgba(44, 36, 32, 0.35);
|
||||
border-radius: 5rpx;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.mc-progress-label {
|
||||
font-size: 20rpx;
|
||||
color: rgba(44, 36, 32, 0.45);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Bottom: dates ────────────────────────── */
|
||||
.mc-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
z-index: 1;
|
||||
padding-top: 4rpx;
|
||||
border-top: 1rpx solid rgba(44, 36, 32, 0.1);
|
||||
}
|
||||
|
||||
.mc-date-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4rpx;
|
||||
}
|
||||
|
||||
.mc-date-sep {
|
||||
width: 1rpx;
|
||||
height: 40rpx;
|
||||
background: rgba(44, 36, 32, 0.12);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mc-date-label {
|
||||
font-size: 20rpx;
|
||||
color: rgba(44, 36, 32, 0.4);
|
||||
}
|
||||
|
||||
.mc-date-value {
|
||||
font-size: 24rpx;
|
||||
color: #2C2420;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Inactive info ────────────────────────── */
|
||||
.mc-inactive-info {
|
||||
display: flex;
|
||||
gap: 40rpx;
|
||||
padding-left: 4rpx;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ── FAB ──────────────────────────────────── */
|
||||
.fab {
|
||||
position: fixed;
|
||||
bottom: calc(32rpx + env(safe-area-inset-bottom));
|
||||
right: 32rpx;
|
||||
background: $brand-color;
|
||||
border-radius: 44rpx;
|
||||
padding: 22rpx 36rpx;
|
||||
box-shadow: 0 6rpx 24rpx rgba(74, 64, 53, 0.25);
|
||||
z-index: 100;
|
||||
|
||||
&:active { opacity: 0.85; }
|
||||
}
|
||||
|
||||
.fab-text {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
|
||||
/* ── Spacer ───────────────────────────────── */
|
||||
.scroll-bottom-spacer {
|
||||
height: 140rpx;
|
||||
}
|
||||
.membership-page { box-sizing: border-box; height: 100vh; display: flex; flex-direction: column; background: #fbf9f6; color: #514943; }
|
||||
.scroll { flex: 1; height: 0; min-height: 0; }
|
||||
.loading-wrap { padding: 28rpx 32rpx; display: flex; flex-direction: column; gap: 24rpx; }
|
||||
.skeleton-card { height: 420rpx; border-radius: 28rpx; background: linear-gradient(90deg, #f0eae5 25%, #faf7f3 50%, #f0eae5 75%); background-size: 400% 100%; animation: shimmer 1.4s infinite; }
|
||||
.empty-wrap { padding: 40rpx 32rpx; }
|
||||
.empty-card { padding: 48rpx 32rpx; border-radius: 28rpx; background: #f2e9e3; display: flex; flex-direction: column; align-items: flex-start; gap: 16rpx; }
|
||||
.empty-title { font-size: 32rpx; font-weight: 500; }
|
||||
.empty-sub { font-size: 24rpx; color: #8b7b70; line-height: 1.7; }
|
||||
.empty-btn { margin: 12rpx 0 0; padding: 0 32rpx; height: 76rpx; line-height: 76rpx; border: none; border-radius: 999rpx; background: #6b8276; color: #fff; font-size: 26rpx; &::after { border: none; } }
|
||||
.list { padding: 28rpx 32rpx 0; }
|
||||
.group-section + .group-section { margin-top: 36rpx; }
|
||||
.group-header { display: flex; align-items: baseline; justify-content: space-between; gap: 20rpx; margin-bottom: 20rpx; }
|
||||
.group-title { font-size: 28rpx; font-weight: 500; }
|
||||
.group-count { font-size: 22rpx; color: #8b817b; }
|
||||
.mc { --balance-bg: #f3ebe3; --balance-ink: #8b6c5b; padding: 28rpx; margin-bottom: 20rpx; border: 1rpx solid #eee8e3; border-radius: 28rpx; background: #fff; }
|
||||
.mc--duration { --balance-bg: #edf2e9; --balance-ink: #617d63; }
|
||||
.mc--trial { --balance-bg: #f5e9e5; --balance-ink: #9b7768; }
|
||||
.mc-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 20rpx; }
|
||||
.mc-name-area { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
|
||||
.mc-name { font-size: 30rpx; font-weight: 500; line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.mc-type-text { font-size: 22rpx; color: #8b817b; }
|
||||
.mc-status { flex-shrink: 0; padding: 6rpx 16rpx; border-radius: 999rpx; background: #ede9e4; color: #8b817b; font-size: 21rpx; line-height: 1.4; }
|
||||
.mc-status--active { background: #edf2ec; color: #617d73; }
|
||||
.mc-balance { margin-top: 24rpx; padding: 22rpx 24rpx; background: var(--balance-bg); border-radius: 20rpx; }
|
||||
.mc-number-row { display: flex; align-items: baseline; gap: 10rpx; }
|
||||
.mc-big-num { font-size: 56rpx; font-weight: 400; line-height: 1.1; color: var(--balance-ink); font-variant-numeric: tabular-nums; }
|
||||
.mc-big-unit { font-size: 23rpx; color: var(--balance-ink); }
|
||||
.mc-duration-note { display: block; font-size: 21rpx; color: #7b8775; margin-top: 12rpx; }
|
||||
.mc-progress { margin-top: 18rpx; }
|
||||
.mc-progress-track { height: 6rpx; border-radius: 6rpx; overflow: hidden; background: rgba(255,255,255,0.8); }
|
||||
.mc-progress-fill { height: 100%; border-radius: 6rpx; background: #b9a38f; }
|
||||
.mc-progress-label { display: block; margin-top: 10rpx; font-size: 21rpx; color: #8b7b70; }
|
||||
.mc-bottom { display: flex; justify-content: space-between; gap: 20rpx; margin-top: 22rpx; }
|
||||
.mc-date-item { min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
|
||||
.mc-date-item--end { text-align: right; }
|
||||
.mc-date-label { font-size: 21rpx; color: #8b817b; line-height: 1.6; }
|
||||
.mc-date-value { font-size: 24rpx; color: #6f655e; font-variant-numeric: tabular-nums; }
|
||||
.mc-actions { display: flex; gap: 16rpx; margin-top: 26rpx; }
|
||||
.mc-renew, .mc-book { flex: 1; margin: 0; padding: 0 24rpx; height: 72rpx; line-height: 72rpx; border: none; border-radius: 999rpx; font-size: 25rpx; font-weight: 400; &::after { border: none; } }
|
||||
.mc-renew { background: #f3eee8; color: #8b7160; }
|
||||
.mc-book { background: #6b8276; color: #fff; }
|
||||
.mc--inactive { background: #f4f1ed; border-color: #eee8e3; .mc-name { font-size: 27rpx; font-weight: 400; color: #786d64; } }
|
||||
.mc-history-bottom { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; margin-top: 22rpx; }
|
||||
.mc-history-info { display: flex; flex-direction: column; gap: 4rpx; }
|
||||
.mc-renew--inactive { flex: none; flex-shrink: 0; height: 64rpx; line-height: 64rpx; background: #fffaf5; font-size: 23rpx; }
|
||||
.purchase-dock { flex-shrink: 0; padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom)); border-top: 1rpx solid #eee8e3; background: #fbf9f6; }
|
||||
.purchase-btn { display: block; width: 100%; margin: 0; padding: 0; height: 84rpx; line-height: 84rpx; border: none; border-radius: 999rpx; background: #6b8276; color: #fff; font-size: 28rpx; font-weight: 400; &::after { border: none; } }
|
||||
.scroll-bottom-spacer { height: 20rpx; }
|
||||
</style>
|
||||
|
||||
249
packages/app/src/pages/profile/teaching-schedule.vue
Normal file
249
packages/app/src/pages/profile/teaching-schedule.vue
Normal file
@@ -0,0 +1,249 @@
|
||||
<template>
|
||||
<view class="schedule-page" :style="{ paddingTop: navBarHeight }">
|
||||
<CustomNavBar title="我的课表" show-back />
|
||||
|
||||
<view class="calendar">
|
||||
<view class="calendar__heading">
|
||||
<picker mode="date" :value="selectedDate" @change="handlePickerChange">
|
||||
<view class="calendar__month">{{ monthLabel }}<text class="calendar__chevron">⌄</text></view>
|
||||
</picker>
|
||||
<button v-if="!isToday(selectedDate)" class="text-button" @tap="selectDate(formatDate(new Date()))">回到今天</button>
|
||||
<text v-else class="calendar__today">今天</text>
|
||||
</view>
|
||||
<view class="week-navigation">
|
||||
<button class="week-arrow" aria-label="上一周" @tap="shiftWeek(-7)">‹</button>
|
||||
<text class="week-navigation__label">{{ weekLabel }}</text>
|
||||
<button class="week-arrow" aria-label="下一周" @tap="shiftWeek(7)">›</button>
|
||||
</view>
|
||||
<view class="week">
|
||||
<button v-for="day in weekDays" :key="day.date" class="day"
|
||||
:class="{ 'day--selected': day.date === selectedDate, 'day--today': isToday(day.date) }"
|
||||
:aria-label="`${day.date} ${day.label}${day.date === selectedDate ? ',已选中' : ''}`"
|
||||
@tap="selectDate(day.date)">
|
||||
<text class="day__label">{{ day.label }}</text>
|
||||
<text class="day__number">{{ day.number }}</text>
|
||||
<view class="day__dot" />
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="agenda-heading">
|
||||
<text class="agenda-heading__date">{{ dateLabel }}</text>
|
||||
<text class="agenda-heading__count">{{ loading ? '正在加载' : error ? '加载失败' : `${slots.length} 节课 · ${studentCount} 人次` }}</text>
|
||||
</view>
|
||||
|
||||
<scroll-view class="schedule-scroll" scroll-y refresher-enabled :refresher-triggered="refreshing"
|
||||
:scroll-top="scrollTop" @scroll="handleScroll" @refresherrefresh="handleRefresh">
|
||||
<view v-if="loading" class="skeleton" aria-label="正在加载课表">
|
||||
<view v-for="i in 3" :key="i" class="skeleton__row"><view class="skeleton__time" /><view class="skeleton__body" /></view>
|
||||
</view>
|
||||
<view v-else-if="error" class="empty">
|
||||
<text class="empty__title">课表暂时未能加载</text>
|
||||
<text class="empty__description">{{ error }}</text>
|
||||
<button class="outline-button" @tap="loadSchedule(selectedDate)">重新加载</button>
|
||||
</view>
|
||||
<view v-else-if="!loggedIn || !isAdmin" class="empty">
|
||||
<text class="empty__title">{{ loggedIn ? '仅管理员可查看课表' : '请先登录' }}</text>
|
||||
<text class="empty__description">返回「我的」查看账号信息</text>
|
||||
</view>
|
||||
<view v-else-if="slots.length === 0" class="empty">
|
||||
<view class="empty__line" />
|
||||
<text class="empty__title">当天暂无预约课程</text>
|
||||
<text class="empty__description">选择其他日期,查看授课安排</text>
|
||||
<button v-if="!isToday(selectedDate)" class="outline-button" @tap="selectDate(formatDate(new Date()))">查看今天</button>
|
||||
</view>
|
||||
<view v-else class="agenda">
|
||||
<view v-for="slot in slots" :key="slot.slotId" class="session">
|
||||
<view class="session__time">
|
||||
<text class="session__start">{{ slot.startTime.slice(0, 5) }}</text>
|
||||
<text class="session__end">{{ slot.endTime.slice(0, 5) }} 结束</text>
|
||||
</view>
|
||||
<view class="session__roster">
|
||||
<view class="session__heading"><text>预约学员</text><text>{{ slot.students.length }} 人</text></view>
|
||||
<view v-for="student in slot.students" :key="student.bookingId" class="student">
|
||||
<view class="student__headline">
|
||||
<text class="student__name">{{ student.nickname || '未命名学员' }}</text>
|
||||
<text class="student__status" :class="`student__status--${student.status.toLowerCase()}`">{{ statusLabel(student.status) }}</text>
|
||||
</view>
|
||||
<button v-if="student.phone" class="student__contact" :aria-label="`联系${student.nickname || '学员'}`" @tap="contactStudent(student.phone)">
|
||||
<text>{{ formatPhone(student.phone) }}</text><text class="student__contact-label">联系 ↗</text>
|
||||
</button>
|
||||
<text v-else class="student__no-phone">未留手机号</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<text class="agenda__end">当天课程已全部显示</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { BookingStatus, type TeachingScheduleSlot } from '@mp-pilates/shared'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
import { useBookingStore } from '../../stores/booking'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
import { formatDate, getWeekdayLabel, isToday } from '../../utils/format'
|
||||
import { getSystemLayout } from '../../utils/system'
|
||||
import { getErrorMessage } from '../../utils/auth'
|
||||
|
||||
const bookingStore = useBookingStore()
|
||||
const { loggedIn, isAdmin } = storeToRefs(useUserStore())
|
||||
const navBarHeight = `${getSystemLayout().navBarHeight}px`
|
||||
const selectedDate = ref(formatDate(new Date()))
|
||||
// Keep the rendered date and request result paired when dates are changed quickly.
|
||||
const slots = ref<TeachingScheduleSlot[]>([])
|
||||
const loading = ref(false)
|
||||
const refreshing = ref(false)
|
||||
const error = ref('')
|
||||
const scrollTop = ref(0)
|
||||
let currentScrollTop = 0
|
||||
let requestId = 0
|
||||
|
||||
function parseDate(value: string) {
|
||||
const [year, month, day] = value.split('-').map(Number)
|
||||
return new Date(year, month - 1, day)
|
||||
}
|
||||
const monthLabel = computed(() => `${selectedDate.value.slice(0, 4)}年 ${Number(selectedDate.value.slice(5, 7))}月`)
|
||||
const dateLabel = computed(() => `${isToday(selectedDate.value) ? '今天 · ' : ''}${Number(selectedDate.value.slice(5, 7))}月${Number(selectedDate.value.slice(8, 10))}日`)
|
||||
const studentCount = computed(() => slots.value.reduce((sum, slot) => sum + slot.students.length, 0))
|
||||
const weekDays = computed(() => {
|
||||
const start = parseDate(selectedDate.value)
|
||||
start.setDate(start.getDate() - (start.getDay() + 6) % 7)
|
||||
return Array.from({ length: 7 }, (_, index) => {
|
||||
const day = new Date(start)
|
||||
day.setDate(start.getDate() + index)
|
||||
const date = formatDate(day)
|
||||
return { date, number: day.getDate(), label: getWeekdayLabel(date).replace('周', '').replace('星期', '') }
|
||||
})
|
||||
})
|
||||
const weekLabel = computed(() => {
|
||||
const first = parseDate(weekDays.value[0].date)
|
||||
const last = parseDate(weekDays.value[6].date)
|
||||
return `${first.getMonth() + 1}月${first.getDate()}日 — ${last.getMonth() + 1}月${last.getDate()}日`
|
||||
})
|
||||
|
||||
onShow(() => { loadSchedule(selectedDate.value) })
|
||||
|
||||
function handlePickerChange(event: { detail: { value: string } }) {
|
||||
selectDate(event.detail.value)
|
||||
}
|
||||
function shiftWeek(days: number) {
|
||||
const date = parseDate(selectedDate.value)
|
||||
date.setDate(date.getDate() + days)
|
||||
selectDate(formatDate(date))
|
||||
}
|
||||
function handleScroll(event: { detail: { scrollTop: number } }) {
|
||||
currentScrollTop = event.detail.scrollTop
|
||||
}
|
||||
async function selectDate(date: string) {
|
||||
if (selectedDate.value === date) return
|
||||
selectedDate.value = date
|
||||
refreshing.value = false
|
||||
scrollTop.value = currentScrollTop
|
||||
await nextTick()
|
||||
scrollTop.value = 0
|
||||
loadSchedule(date)
|
||||
}
|
||||
async function handleRefresh() {
|
||||
if (refreshing.value) return
|
||||
refreshing.value = true
|
||||
try { await loadSchedule(selectedDate.value) }
|
||||
finally { refreshing.value = false }
|
||||
}
|
||||
async function loadSchedule(date: string) {
|
||||
const id = ++requestId
|
||||
error.value = ''
|
||||
slots.value = []
|
||||
if (!loggedIn.value || !isAdmin.value) { loading.value = false; return }
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await bookingStore.fetchTeachingSchedule(date)
|
||||
if (id === requestId) slots.value = [...result].sort((a, b) => a.startTime.localeCompare(b.startTime))
|
||||
} catch (err: unknown) {
|
||||
if (id === requestId) error.value = getErrorMessage(err, '请检查网络后重试')
|
||||
} finally {
|
||||
if (id === requestId) loading.value = false
|
||||
}
|
||||
}
|
||||
function formatPhone(phone: string) {
|
||||
return /^\d{11}$/.test(phone) ? `${phone.slice(0, 3)} ${phone.slice(3, 7)} ${phone.slice(7)}` : phone
|
||||
}
|
||||
function contactStudent(phone: string) {
|
||||
uni.makePhoneCall({ phoneNumber: phone })
|
||||
}
|
||||
const STATUS_LABELS: Record<BookingStatus, string> = {
|
||||
[BookingStatus.PENDING_CONFIRMATION]: '待确认',
|
||||
[BookingStatus.CONFIRMED]: '已确认',
|
||||
[BookingStatus.CANCELLED]: '已取消',
|
||||
[BookingStatus.COMPLETED]: '已完成',
|
||||
[BookingStatus.NO_SHOW]: '未出席',
|
||||
}
|
||||
function statusLabel(status: BookingStatus) { return STATUS_LABELS[status] ?? status }
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.schedule-page {
|
||||
height: 100vh;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: #fbf9f6;
|
||||
color: #514943;
|
||||
}
|
||||
button { margin: 0; padding: 0; background: transparent; font-weight: 400; border-radius: 0; &::after { border: none; } }
|
||||
.calendar { flex-shrink: 0; padding: 22rpx 32rpx 20rpx; background: #fff; }
|
||||
.calendar__heading { display: flex; align-items: center; justify-content: space-between; min-height: 76rpx; }
|
||||
.calendar__month { padding: 16rpx 0; font-size: 36rpx; font-family: 'Songti SC', 'STSong', serif; }
|
||||
.calendar__chevron { margin-left: 16rpx; font-size: 26rpx; color: #81776f; }
|
||||
.text-button, .calendar__today { font-size: 24rpx; color: #526e62; }
|
||||
.text-button { line-height: 76rpx; padding-left: 24rpx; }
|
||||
.week-navigation { display: flex; align-items: center; justify-content: space-between; margin: 0 -12rpx 8rpx; }
|
||||
.week-navigation__label { font-size: 23rpx; color: #81776f; }
|
||||
.week-arrow { width: 80rpx; height: 76rpx; line-height: 70rpx; font-size: 40rpx; color: #70665e; }
|
||||
.week { display: flex; justify-content: space-between; gap: 6rpx; }
|
||||
.day { flex: 1; min-width: 0; height: 120rpx; display: flex; flex-direction: column; align-items: center; justify-content: center; line-height: 1; border-radius: 40rpx; color: #514943; }
|
||||
.day__label { font-size: 22rpx; color: #81776f; }
|
||||
.day__number { margin-top: 16rpx; font-size: 32rpx; font-variant-numeric: tabular-nums; }
|
||||
.day__dot { margin-top: 10rpx; height: 6rpx; width: 6rpx; border-radius: 50%; background: transparent; }
|
||||
.day--today .day__dot { background: #526e62; }
|
||||
.day--selected { background: #526e62; color: #fff; .day__label { color: #fff; } .day__dot { background: transparent; } }
|
||||
.day--selected.day--today .day__dot { background: #fff; }
|
||||
.agenda-heading { flex-shrink: 0; display: flex; justify-content: space-between; align-items: baseline; gap: 16rpx; padding: 32rpx; border-top: 1rpx solid #eee9e2; }
|
||||
.agenda-heading__date { font-size: 28rpx; font-weight: 500; }
|
||||
.agenda-heading__count { font-size: 23rpx; color: #81776f; }
|
||||
.schedule-scroll { flex: 1; min-height: 0; height: 0; }
|
||||
.agenda { padding: 0 32rpx calc(40rpx + env(safe-area-inset-bottom)); }
|
||||
.session { margin-bottom: 24rpx; padding: 0 28rpx; overflow: hidden; background: #fff; border: 1rpx solid #deded5; border-radius: 20rpx; }
|
||||
.session__time { display: flex; align-items: baseline; gap: 20rpx; margin: 0 -28rpx; padding: 24rpx 28rpx; background: #eef2ed; border-bottom: 1rpx solid #dde4da; }
|
||||
.session__start { display: block; font-size: 36rpx; font-variant-numeric: tabular-nums; font-weight: 500; }
|
||||
.session__end { font-size: 24rpx; color: #687367; }
|
||||
.session__roster { min-width: 0; }
|
||||
.session__heading { display: flex; justify-content: space-between; padding: 24rpx 0 4rpx; font-size: 23rpx; color: #766d64; }
|
||||
.student { padding: 24rpx 0 16rpx; border-bottom: 1rpx solid #e8e2da; }
|
||||
.student:last-child { border-bottom: none; }
|
||||
.student__headline { display: flex; align-items: baseline; justify-content: space-between; gap: 12rpx; }
|
||||
.student__name { min-width: 0; font-size: 30rpx; font-weight: 500; line-height: 1.5; overflow-wrap: anywhere; word-break: break-all; }
|
||||
.student__status { flex-shrink: 0; padding: 6rpx 12rpx; border-radius: 6rpx; background: #f2f0ec; font-size: 22rpx; line-height: 1.4; color: #766d64; }
|
||||
.student__status--confirmed { background: #edf3ed; color: #526e62; }
|
||||
.student__status--pending_confirmation { background: #f8f0e3; color: #956b37; }
|
||||
.student__status--no_show { background: #f8eeea; color: #a06456; }
|
||||
.student__contact { width: 100%; min-height: 76rpx; line-height: 1.4; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8rpx; text-align: left; font-size: 23rpx; color: #81776f; font-variant-numeric: tabular-nums; }
|
||||
.student__contact-label { color: #526e62; font-size: 22rpx; }
|
||||
.student__no-phone { display: block; padding: 18rpx 0; font-size: 23rpx; color: #81776f; }
|
||||
.agenda__end { display: block; padding: 12rpx 0 24rpx; text-align: center; color: #81776f; font-size: 21rpx; }
|
||||
.empty { padding: 100rpx 48rpx 48rpx; display: flex; flex-direction: column; align-items: center; text-align: center; }
|
||||
.empty__line { height: 48rpx; width: 1rpx; background: #b4c1b7; margin-bottom: 32rpx; }
|
||||
.empty__title { font-family: 'Songti SC', 'STSong', serif; font-size: 34rpx; }
|
||||
.empty__description { margin-top: 20rpx; font-size: 25rpx; color: #81776f; line-height: 1.7; }
|
||||
.outline-button { margin-top: 36rpx; padding: 0 36rpx; min-height: 80rpx; line-height: 80rpx; border: 1rpx solid #bcc7bf; border-radius: 8rpx; color: #526e62; font-size: 25rpx; }
|
||||
.skeleton { padding: 12rpx 32rpx; }
|
||||
.skeleton__row { display: flex; gap: 24rpx; margin-bottom: 40rpx; }
|
||||
.skeleton__time { width: 116rpx; height: 40rpx; background: #eae6df; border-radius: 4rpx; }
|
||||
.skeleton__body { flex: 1; height: 180rpx; background: #eeebe5; border-radius: 4rpx; }
|
||||
button:active { opacity: .7; }
|
||||
</style>
|
||||
@@ -6,7 +6,7 @@
|
||||
<view class="nav-spacer" :style="{ height: navBarHeight }" />
|
||||
|
||||
<view class="hero-section">
|
||||
<image class="hero-image" :src="teacher.cover" mode="aspectFill" />
|
||||
<image class="hero-image" :src="teacher.cover" mode="widthFix" />
|
||||
<view class="hero-overlay" />
|
||||
|
||||
<view class="hero-content">
|
||||
@@ -43,11 +43,11 @@
|
||||
|
||||
<view class="detail-card">
|
||||
<text class="card-title">教练介绍</text>
|
||||
<text class="card-text">
|
||||
Iris 注重基础控制与身体感知,课程会围绕呼吸、核心稳定、动作路径和发力节奏展开,帮助学员在安全前提下持续进步。
|
||||
<text class="card-text card-text--intro">
|
||||
我是 Iris,一名注重体态、控制与身体感受的普拉提教练。
|
||||
</text>
|
||||
<text class="card-text">
|
||||
无论你希望改善久坐体态、建立训练基础,还是进一步雕塑身体线条,都可以通过私教课程获得更清晰的动作反馈与进阶方案。
|
||||
我希望带你在稳定、安心的节奏里,找回核心力量、挺拔线条和更轻松的身体状态。
|
||||
</text>
|
||||
</view>
|
||||
|
||||
@@ -71,7 +71,17 @@
|
||||
|
||||
<view class="detail-card cta-card">
|
||||
<text class="card-title">适合这样的你</text>
|
||||
<text class="card-text">久坐肩颈紧张、想改善圆肩骨盆前倾、训练没感觉,或想通过更稳定的核心训练提升身体线条与控制力。</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>
|
||||
@@ -96,7 +106,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { onLoad, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
import { irisProfile } from '../../utils/teacher'
|
||||
import { getSystemLayout } from '../../utils/system'
|
||||
@@ -105,6 +115,22 @@ 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()
|
||||
})
|
||||
@@ -141,7 +167,7 @@ function goToBooking() {
|
||||
|
||||
.hero-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.hero-overlay {
|
||||
@@ -305,6 +331,13 @@ function goToBooking() {
|
||||
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;
|
||||
@@ -358,6 +391,22 @@ function goToBooking() {
|
||||
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;
|
||||
|
||||
@@ -2,8 +2,6 @@ import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { get, post, put, del } from '../utils/request'
|
||||
import type {
|
||||
WeekTemplate,
|
||||
WeekTemplateInput,
|
||||
CardType,
|
||||
CreateCardTypeDto,
|
||||
UpdateCardTypeDto,
|
||||
@@ -18,6 +16,16 @@ import type {
|
||||
FlashSaleAdminItem,
|
||||
CreateFlashSaleDto,
|
||||
UpdateFlashSaleDto,
|
||||
CreateStudioUploadCredentialDto,
|
||||
StudioUploadCredential,
|
||||
AdminMemberSummary,
|
||||
AdminMemberDetail,
|
||||
LessonSupplementRecord,
|
||||
CreateLessonSupplementDto,
|
||||
UpdateAdminMemberProfileDto,
|
||||
AdminArrangeBookingDto,
|
||||
MembershipWithCardType,
|
||||
BookingWithDetails,
|
||||
} from '@mp-pilates/shared'
|
||||
|
||||
interface LegacyPaginatedData<T> {
|
||||
@@ -53,16 +61,7 @@ export interface AdminStats {
|
||||
totalBookings: number
|
||||
}
|
||||
|
||||
export interface MemberSummary {
|
||||
userId: string
|
||||
openid: string
|
||||
nickname: string
|
||||
phone: string | null
|
||||
avatarUrl: string | null
|
||||
totalBookings: number
|
||||
completedBookings: number
|
||||
cancelledBookings: number
|
||||
}
|
||||
export type MemberSummary = AdminMemberSummary
|
||||
|
||||
export interface UserMembership {
|
||||
userId: string
|
||||
@@ -84,21 +83,6 @@ export interface UserMembership {
|
||||
}
|
||||
|
||||
export const useAdminStore = defineStore('admin', () => {
|
||||
// ── Week templates ───────────────────────────────────────────────
|
||||
const weekTemplates = ref<WeekTemplate[]>([])
|
||||
|
||||
async function fetchWeekTemplates(): Promise<WeekTemplate[]> {
|
||||
const data = await get<WeekTemplate[]>('/admin/week-template')
|
||||
weekTemplates.value = data
|
||||
return data
|
||||
}
|
||||
|
||||
async function saveWeekTemplates(templates: WeekTemplateInput[]): Promise<WeekTemplate[]> {
|
||||
const data = await put<WeekTemplate[]>('/admin/week-template', { templates })
|
||||
weekTemplates.value = data
|
||||
return data
|
||||
}
|
||||
|
||||
// ── Card types ───────────────────────────────────────────────────
|
||||
const cardTypes = ref<CardType[]>([])
|
||||
|
||||
@@ -141,6 +125,15 @@ export const useAdminStore = defineStore('admin', () => {
|
||||
return data
|
||||
}
|
||||
|
||||
async function createStudioUploadCredential(
|
||||
dto: CreateStudioUploadCredentialDto,
|
||||
): Promise<StudioUploadCredential> {
|
||||
return post<StudioUploadCredential>(
|
||||
'/admin/studio/upload-credentials',
|
||||
dto as unknown as Record<string, unknown>,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Orders ───────────────────────────────────────────────────────
|
||||
async function fetchAdminOrders(params: {
|
||||
page?: number
|
||||
@@ -182,20 +175,50 @@ export const useAdminStore = defineStore('admin', () => {
|
||||
return get<UserMembership>(`/admin/members/${userId}/membership`)
|
||||
}
|
||||
|
||||
async function fetchLessonSupplements(userId: string): Promise<LessonSupplementRecord[]> {
|
||||
return get<LessonSupplementRecord[]>(`/admin/members/${userId}/lesson-supplements`)
|
||||
}
|
||||
|
||||
async function createLessonSupplement(userId: string, dto: CreateLessonSupplementDto): Promise<LessonSupplementRecord> {
|
||||
return post<LessonSupplementRecord>(`/admin/members/${userId}/lesson-supplements`, { ...dto })
|
||||
}
|
||||
|
||||
async function revokeLessonSupplement(userId: string, id: string): Promise<void> {
|
||||
await post(`/admin/members/${userId}/lesson-supplements/${id}/revoke`)
|
||||
}
|
||||
|
||||
async function fetchMemberDetail(userId: string): Promise<AdminMemberDetail> {
|
||||
return get<AdminMemberDetail>(`/admin/members/${userId}`)
|
||||
}
|
||||
|
||||
async function updateMemberProfile(
|
||||
userId: string,
|
||||
dto: UpdateAdminMemberProfileDto,
|
||||
): Promise<AdminMemberDetail> {
|
||||
return put<AdminMemberDetail>(`/admin/members/${userId}`, dto as unknown as Record<string, unknown>)
|
||||
}
|
||||
|
||||
async function arrangeMemberBooking(dto: AdminArrangeBookingDto): Promise<BookingWithDetails> {
|
||||
return post<BookingWithDetails>('/admin/bookings', dto as unknown as Record<string, unknown>)
|
||||
}
|
||||
|
||||
async function updateUserMembership(
|
||||
userId: string,
|
||||
dto: {
|
||||
membershipId?: string
|
||||
cardTypeId: string
|
||||
remainingTimes?: number | null
|
||||
startDate: string
|
||||
expireDate: string
|
||||
},
|
||||
): Promise<any> {
|
||||
return put<any>(`/admin/members/${userId}/membership`, dto)
|
||||
): Promise<MembershipWithCardType> {
|
||||
return put<MembershipWithCardType>(`/admin/members/${userId}/membership`, dto)
|
||||
}
|
||||
|
||||
async function deleteUserMembership(userId: string): Promise<void> {
|
||||
return del<void>(`/admin/members/${userId}/membership`)
|
||||
async function deleteUserMembership(userId: string, membershipId: string): Promise<void> {
|
||||
return del<void>(
|
||||
`/admin/members/${userId}/membership?membershipId=${encodeURIComponent(membershipId)}`,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Time slots ───────────────────────────────────────────────────
|
||||
@@ -222,7 +245,7 @@ export const useAdminStore = defineStore('admin', () => {
|
||||
async function fetchSchedulePreview(date: string): Promise<ScheduleSlotPreview[]> {
|
||||
scheduleLoading.value = true
|
||||
try {
|
||||
const data = await get<ScheduleSlotPreview[]>('/admin/schedule/preview', { date })
|
||||
const data = await previewScheduleByDate(date)
|
||||
schedulePreview.value = data
|
||||
return data
|
||||
} finally {
|
||||
@@ -230,6 +253,10 @@ 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> {
|
||||
await post('/admin/schedule/publish', dto as unknown as Record<string, unknown>)
|
||||
await fetchSchedulePreview(dto.date)
|
||||
@@ -262,14 +289,10 @@ export const useAdminStore = defineStore('admin', () => {
|
||||
|
||||
return {
|
||||
// State
|
||||
weekTemplates,
|
||||
cardTypes,
|
||||
studioConfig,
|
||||
schedulePreview,
|
||||
scheduleLoading,
|
||||
// Week templates
|
||||
fetchWeekTemplates,
|
||||
saveWeekTemplates,
|
||||
// Card types
|
||||
fetchCardTypes,
|
||||
createCardType,
|
||||
@@ -278,12 +301,19 @@ export const useAdminStore = defineStore('admin', () => {
|
||||
// Studio
|
||||
fetchStudioConfig,
|
||||
saveStudioConfig,
|
||||
createStudioUploadCredential,
|
||||
// Orders
|
||||
fetchAdminOrders,
|
||||
// Bookings
|
||||
fetchAdminBookings,
|
||||
// Members
|
||||
fetchMembers,
|
||||
fetchMemberDetail,
|
||||
fetchLessonSupplements,
|
||||
createLessonSupplement,
|
||||
revokeLessonSupplement,
|
||||
updateMemberProfile,
|
||||
arrangeMemberBooking,
|
||||
getUserMembership,
|
||||
updateUserMembership,
|
||||
deleteUserMembership,
|
||||
@@ -294,6 +324,7 @@ export const useAdminStore = defineStore('admin', () => {
|
||||
generateSlots,
|
||||
// Schedule
|
||||
fetchSchedulePreview,
|
||||
previewScheduleByDate,
|
||||
publishDaySlots,
|
||||
// Stats
|
||||
fetchDashboardStats,
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
BookingWithUser,
|
||||
BookingStatusHistory,
|
||||
CreateBookingDto,
|
||||
TeachingScheduleSlot,
|
||||
} from '@mp-pilates/shared'
|
||||
import { get, post, put } from '../utils/request'
|
||||
|
||||
@@ -21,8 +22,10 @@ export const useBookingStore = defineStore('booking', () => {
|
||||
const slots = ref<readonly TimeSlotWithBookingStatus[]>([])
|
||||
const myBookings = ref<readonly BookingWithDetails[]>([])
|
||||
const upcomingBookings = ref<readonly BookingWithDetails[]>([])
|
||||
const teachingSchedule = ref<readonly TeachingScheduleSlot[]>([])
|
||||
const loadingSlots = ref(false)
|
||||
const loadingBookings = ref(false)
|
||||
const loadingTeachingSchedule = ref(false)
|
||||
|
||||
async function fetchSlots(date: string) {
|
||||
loadingSlots.value = true
|
||||
@@ -41,13 +44,27 @@ export const useBookingStore = defineStore('booking', () => {
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a booking in `myBookings` by id. Preserves immutability: always
|
||||
* returns a new array reference so Vue's computed/watchers pick up the change.
|
||||
* If the booking isn't in the list (e.g. paginated out), leaves state untouched.
|
||||
*/
|
||||
function replaceBooking(updated: BookingWithDetails) {
|
||||
const idx = myBookings.value.findIndex((b) => b.id === updated.id)
|
||||
if (idx === -1) return
|
||||
const next = myBookings.value.slice()
|
||||
next[idx] = updated
|
||||
myBookings.value = next
|
||||
}
|
||||
|
||||
async function cancelBooking(bookingId: string) {
|
||||
const result = await put<BookingWithDetails>(`/booking/${bookingId}/cancel`)
|
||||
replaceBooking(result)
|
||||
return result
|
||||
}
|
||||
|
||||
async function fetchMyBookings(status?: string) {
|
||||
loadingBookings.value = true
|
||||
async function fetchMyBookings(status?: string, opts: { silent?: boolean } = {}) {
|
||||
if (!opts.silent) loadingBookings.value = true
|
||||
try {
|
||||
const params: Record<string, unknown> = status ? { status } : {}
|
||||
const paginated = await get<ServerPaginatedResult<BookingWithDetails>>('/booking/my', params)
|
||||
@@ -56,7 +73,7 @@ export const useBookingStore = defineStore('booking', () => {
|
||||
console.error('Fetch bookings failed:', err)
|
||||
myBookings.value = []
|
||||
} finally {
|
||||
loadingBookings.value = false
|
||||
if (!opts.silent) loadingBookings.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +87,21 @@ export const useBookingStore = defineStore('booking', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTeachingSchedule(date: string) {
|
||||
loadingTeachingSchedule.value = true
|
||||
try {
|
||||
const result = await get<TeachingScheduleSlot[]>('/admin/teaching-schedule', { date })
|
||||
teachingSchedule.value = Array.isArray(result) ? result : []
|
||||
return teachingSchedule.value
|
||||
} catch (err) {
|
||||
console.error('Fetch teaching schedule failed:', err)
|
||||
teachingSchedule.value = []
|
||||
throw err
|
||||
} finally {
|
||||
loadingTeachingSchedule.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Admin methods ──────────────────────────────────────────────────────
|
||||
|
||||
async function fetchAllAdminBookings(
|
||||
@@ -88,6 +120,7 @@ export const useBookingStore = defineStore('booking', () => {
|
||||
const result = await put<BookingWithDetails>(`/booking/${bookingId}/confirm`, {
|
||||
remark,
|
||||
})
|
||||
replaceBooking(result)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -95,6 +128,7 @@ export const useBookingStore = defineStore('booking', () => {
|
||||
const result = await put<BookingWithDetails>(`/booking/${bookingId}/complete`, {
|
||||
remark,
|
||||
})
|
||||
replaceBooking(result)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -102,6 +136,7 @@ export const useBookingStore = defineStore('booking', () => {
|
||||
const result = await put<BookingWithDetails>(`/booking/${bookingId}/noshow`, {
|
||||
remark,
|
||||
})
|
||||
replaceBooking(result)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -124,13 +159,16 @@ export const useBookingStore = defineStore('booking', () => {
|
||||
slots,
|
||||
myBookings,
|
||||
upcomingBookings,
|
||||
teachingSchedule,
|
||||
loadingSlots,
|
||||
loadingBookings,
|
||||
loadingTeachingSchedule,
|
||||
fetchSlots,
|
||||
createBooking,
|
||||
cancelBooking,
|
||||
fetchMyBookings,
|
||||
fetchUpcomingBookings,
|
||||
fetchTeachingSchedule,
|
||||
fetchAllAdminBookings,
|
||||
confirmBooking,
|
||||
completeBooking,
|
||||
@@ -138,5 +176,6 @@ export const useBookingStore = defineStore('booking', () => {
|
||||
fetchBookingHistory,
|
||||
fetchSlotById,
|
||||
fetchBookingById,
|
||||
replaceBooking,
|
||||
}
|
||||
})
|
||||
|
||||
26
packages/app/src/stores/invite.ts
Normal file
26
packages/app/src/stores/invite.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { InviteActivitySummary } from '@mp-pilates/shared'
|
||||
import { get } from '../utils/request'
|
||||
|
||||
export const useInviteStore = defineStore('invite', () => {
|
||||
const activity = ref<InviteActivitySummary | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
async function fetchActivity() {
|
||||
loading.value = true
|
||||
try {
|
||||
activity.value = await get<InviteActivitySummary>('/invite/activity')
|
||||
return activity.value
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
activity,
|
||||
loading,
|
||||
fetchActivity,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -5,12 +5,21 @@ import type {
|
||||
UserStatsResponse,
|
||||
MembershipWithCardType,
|
||||
} from '@mp-pilates/shared'
|
||||
import { UserRole, MembershipStatus } from '@mp-pilates/shared'
|
||||
import {
|
||||
UserRole,
|
||||
MembershipStatus,
|
||||
getMembershipRenewalHint,
|
||||
} from '@mp-pilates/shared'
|
||||
import { wxLogin, isLoggedIn, logout as authLogout } from '../utils/auth'
|
||||
import { setUnauthorizedHandler } from '../utils/session'
|
||||
import { get, put } from '../utils/request'
|
||||
import { ROUTES } from '../utils/routes'
|
||||
import { cacheSubscriptionMessageTemplateConfig, resetSubscriptionMessageTemplateCache } from '../utils/wechat-subscription'
|
||||
|
||||
function syncSubscriptionTemplates(profile?: Pick<UserProfileResponse, 'subscriptionMessageTemplates'> | null) {
|
||||
cacheSubscriptionMessageTemplateConfig(profile?.subscriptionMessageTemplates)
|
||||
}
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
// State
|
||||
const user = ref<UserProfileResponse | null>(null)
|
||||
@@ -28,6 +37,8 @@ export const useUserStore = defineStore('user', () => {
|
||||
memberships.value.filter((m) => m.status === MembershipStatus.ACTIVE),
|
||||
)
|
||||
const hasValidMembership = computed(() => activeMemberships.value.length > 0)
|
||||
const renewalHint = computed(() => getMembershipRenewalHint(memberships.value))
|
||||
const inviteShareEligible = computed(() => !!user.value?.inviteShareEligible)
|
||||
|
||||
// Actions
|
||||
async function login() {
|
||||
@@ -35,7 +46,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
const result = await wxLogin()
|
||||
token.value = result.token
|
||||
user.value = result.user
|
||||
cacheSubscriptionMessageTemplateConfig(result.user.subscriptionMessageTemplates)
|
||||
syncSubscriptionTemplates(result.user)
|
||||
return { user: result.user, isNewUser: result.isNewUser }
|
||||
} catch (err) {
|
||||
console.error('Login failed:', err)
|
||||
@@ -61,7 +72,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
if (!isLoggedIn()) return
|
||||
try {
|
||||
user.value = await get<UserProfileResponse>('/user/profile')
|
||||
cacheSubscriptionMessageTemplateConfig(user.value.subscriptionMessageTemplates)
|
||||
syncSubscriptionTemplates(user.value)
|
||||
return user.value
|
||||
} catch (err) {
|
||||
console.error('Fetch profile failed:', err)
|
||||
@@ -77,25 +88,27 @@ export const useUserStore = defineStore('user', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMemberships() {
|
||||
if (!isLoggedIn()) return
|
||||
async function fetchMemberships(): Promise<boolean> {
|
||||
if (!isLoggedIn()) return false
|
||||
try {
|
||||
memberships.value = await get<MembershipWithCardType[]>('/membership/my')
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('Fetch memberships failed:', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function updateProfile(data: { nickname?: string; avatarUrl?: string }) {
|
||||
const updated = await put<UserProfileResponse>('/user/profile', data)
|
||||
user.value = updated
|
||||
cacheSubscriptionMessageTemplateConfig(updated.subscriptionMessageTemplates)
|
||||
syncSubscriptionTemplates(updated)
|
||||
return updated
|
||||
}
|
||||
|
||||
function setProfile(profile: UserProfileResponse) {
|
||||
user.value = profile
|
||||
cacheSubscriptionMessageTemplateConfig(profile.subscriptionMessageTemplates)
|
||||
syncSubscriptionTemplates(profile)
|
||||
}
|
||||
|
||||
function checkAuth() {
|
||||
@@ -105,15 +118,21 @@ export const useUserStore = defineStore('user', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
authLogout()
|
||||
resetSubscriptionMessageTemplateCache()
|
||||
function clearSession() {
|
||||
token.value = ''
|
||||
user.value = null
|
||||
stats.value = null
|
||||
memberships.value = []
|
||||
resetSubscriptionMessageTemplateCache()
|
||||
}
|
||||
|
||||
function logout() {
|
||||
authLogout()
|
||||
clearSession()
|
||||
}
|
||||
|
||||
setUnauthorizedHandler(clearSession)
|
||||
|
||||
return {
|
||||
user,
|
||||
stats,
|
||||
@@ -124,6 +143,8 @@ export const useUserStore = defineStore('user', () => {
|
||||
isAdmin,
|
||||
activeMemberships,
|
||||
hasValidMembership,
|
||||
renewalHint,
|
||||
inviteShareEligible,
|
||||
login,
|
||||
loginWithSetup,
|
||||
fetchProfile,
|
||||
|
||||
@@ -54,6 +54,8 @@ export function getErrorMessage(err: unknown, fallback: string): string {
|
||||
}
|
||||
|
||||
export async function wxLogin(): Promise<LoginResponse> {
|
||||
const inviterId = uni.getStorageSync('invite_inviter_id') as string
|
||||
|
||||
await ensurePrivacyAuthorization()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -72,8 +74,12 @@ export async function wxLogin(): Promise<LoginResponse> {
|
||||
// 新用户的昵称/头像由后端生成默认值,用户可在个人资料页修改
|
||||
const result = await post<LoginResponse>('/auth/login', {
|
||||
code: loginRes.code,
|
||||
inviterId: inviterId || undefined,
|
||||
})
|
||||
uni.setStorageSync('token', result.token)
|
||||
if (result.isNewUser && inviterId) {
|
||||
uni.removeStorageSync('invite_inviter_id')
|
||||
}
|
||||
resolve(result)
|
||||
} catch (err) {
|
||||
reject(err)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { FlashSalePhase } from '@mp-pilates/shared'
|
||||
/** Minimal membership shape needed by progress/usage helpers. */
|
||||
interface MembershipLike {
|
||||
readonly remainingTimes: number | null
|
||||
readonly totalTimes?: number | null
|
||||
readonly cardType: { readonly totalTimes: number | null }
|
||||
}
|
||||
|
||||
@@ -84,17 +85,24 @@ export function getCardGradientClass(type: CardTypeCategory | string): string {
|
||||
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 {
|
||||
if (membership.remainingTimes === null || !membership.cardType.totalTimes) return '0%'
|
||||
const pct = (membership.remainingTimes / membership.cardType.totalTimes) * 100
|
||||
const totalTimes = getMembershipTotalTimes(membership)
|
||||
if (membership.remainingTimes === null || !totalTimes) return '0%'
|
||||
const pct = (membership.remainingTimes / totalTimes) * 100
|
||||
return `${Math.max(0, Math.min(100, pct))}%`
|
||||
}
|
||||
|
||||
/** 已使用次数(不低于 0,防止管理员调高剩余次数导致负值) */
|
||||
export function getMembershipUsedTimes(membership: MembershipLike): number {
|
||||
if (membership.remainingTimes === null || !membership.cardType.totalTimes) return 0
|
||||
return Math.max(0, membership.cardType.totalTimes - membership.remainingTimes)
|
||||
const totalTimes = getMembershipTotalTimes(membership)
|
||||
if (membership.remainingTimes === null || !totalTimes) return 0
|
||||
return Math.max(0, totalTimes - membership.remainingTimes)
|
||||
}
|
||||
|
||||
/** 格式化倒计时:HH:MM:SS */
|
||||
@@ -135,6 +143,18 @@ 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)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ApiResponse, PaginatedData } from '@mp-pilates/shared'
|
||||
import { notifyUnauthorized } from './session'
|
||||
|
||||
// 统一使用线上服务地址
|
||||
const BASE_URL = 'https://focus.richarjiang.com/api'
|
||||
@@ -10,6 +11,13 @@ interface RequestOptions {
|
||||
readonly header?: Record<string, string>
|
||||
}
|
||||
|
||||
export class HttpRequestError extends Error {
|
||||
constructor(message: string, readonly statusCode: number) {
|
||||
super(message)
|
||||
this.name = 'HttpRequestError'
|
||||
}
|
||||
}
|
||||
|
||||
export function request<T>(options: RequestOptions): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const token = uni.getStorageSync('token') as string
|
||||
@@ -25,14 +33,15 @@ export function request<T>(options: RequestOptions): Promise<T> {
|
||||
},
|
||||
success: (res) => {
|
||||
if (res.statusCode === 401) {
|
||||
uni.removeStorageSync('token')
|
||||
uni.showToast({ title: '请重新登录', icon: 'none' })
|
||||
if (!options.url.startsWith('/auth/login')) {
|
||||
notifyUnauthorized()
|
||||
}
|
||||
reject(new Error('Unauthorized'))
|
||||
return
|
||||
}
|
||||
if (res.statusCode >= 400) {
|
||||
const body = res.data as ApiResponse<unknown>
|
||||
reject(new Error(body?.message || `请求失败 (${res.statusCode})`))
|
||||
reject(new HttpRequestError(body?.message || `请求失败 (${res.statusCode})`, res.statusCode))
|
||||
return
|
||||
}
|
||||
const body = res.data as ApiResponse<T>
|
||||
|
||||
67
packages/app/src/utils/schedule-time.ts
Normal file
67
packages/app/src/utils/schedule-time.ts
Normal 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))
|
||||
}
|
||||
22
packages/app/src/utils/session.ts
Normal file
22
packages/app/src/utils/session.ts
Normal 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' })
|
||||
}
|
||||
}
|
||||
72
packages/app/src/utils/studio-upload.ts
Normal file
72
packages/app/src/utils/studio-upload.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type {
|
||||
CreateStudioUploadCredentialDto,
|
||||
StudioAssetType,
|
||||
StudioUploadCredential,
|
||||
} from '@mp-pilates/shared'
|
||||
import type { useAdminStore } from '../stores/admin'
|
||||
|
||||
type AdminStore = ReturnType<typeof useAdminStore>
|
||||
|
||||
function inferContentType(fileName: string): string | undefined {
|
||||
const extension = fileName.split('.').pop()?.toLowerCase()
|
||||
|
||||
if (extension === 'jpg' || extension === 'jpeg') {
|
||||
return 'image/jpeg'
|
||||
}
|
||||
if (extension === 'png') {
|
||||
return 'image/png'
|
||||
}
|
||||
if (extension === 'webp') {
|
||||
return 'image/webp'
|
||||
}
|
||||
if (extension === 'heic') {
|
||||
return 'image/heic'
|
||||
}
|
||||
if (extension === 'heif') {
|
||||
return 'image/heif'
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function uploadToCos(filePath: string, credential: StudioUploadCredential): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.uploadFile({
|
||||
url: credential.uploadUrl,
|
||||
filePath,
|
||||
name: 'file',
|
||||
formData: credential.formData as unknown as Record<string, string>,
|
||||
success: (result) => {
|
||||
if (result.statusCode >= 200 && result.statusCode < 300) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
const body = typeof result.data === 'string' ? result.data : JSON.stringify(result.data)
|
||||
const code = body.match(/<Code>([^<]+)<\/Code>/)?.[1]
|
||||
const message = body.match(/<Message>([^<]+)<\/Message>/)?.[1]
|
||||
const detail = code || message ? `${code ?? 'COS'}: ${message ?? body}` : body
|
||||
reject(new Error(`COS 上传失败 (${result.statusCode}) ${detail}`))
|
||||
},
|
||||
fail: (error) => {
|
||||
reject(new Error(error.errMsg || 'COS 上传失败'))
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function uploadStudioAsset(params: {
|
||||
adminStore: AdminStore
|
||||
filePath: string
|
||||
fileName: string
|
||||
assetType: StudioAssetType
|
||||
}): Promise<string> {
|
||||
const payload: CreateStudioUploadCredentialDto = {
|
||||
fileName: params.fileName,
|
||||
contentType: inferContentType(params.fileName),
|
||||
assetType: params.assetType,
|
||||
}
|
||||
const credential = await params.adminStore.createStudioUploadCredential(payload)
|
||||
await uploadToCos(params.filePath, credential)
|
||||
return credential.fileUrl
|
||||
}
|
||||
@@ -16,15 +16,17 @@ export const irisProfile: TeacherProfile = {
|
||||
id: 'iris',
|
||||
name: 'Iris',
|
||||
title: '高级普拉提教练',
|
||||
avatar: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/images/person_desc.jpeg',
|
||||
cover: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/images/person_desc.jpeg',
|
||||
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: '擅长用循序渐进的核心训练帮助学员改善姿态、建立稳定发力模式,让训练效果更细腻也更可持续。',
|
||||
intro: '我擅长把专业训练拆解成身体真正听得懂的节奏,让你在安全、稳定与被看见的陪伴里,一点点找回力量、线条与自信。',
|
||||
certifications: [
|
||||
'加拿大 STOTT PILATES 垫上初中级认证',
|
||||
'加拿大 STOTT PILATES 塑身机初中级认证教练',
|
||||
'系统化接受 STOTT PILATES 体系训练',
|
||||
'斯多特塑身机初&中级认证教练',
|
||||
'斯多特全场馆认证教练',
|
||||
'曼丽丘斯多特塑身机弹跳网芭杆工作坊',
|
||||
'塑身机上的高尔夫与旋转运动调理工作坊',
|
||||
'四维人体运动解剖认证',
|
||||
],
|
||||
teachingFocus: [
|
||||
{
|
||||
|
||||
@@ -103,10 +103,18 @@ async function fetchTemplateConfig(): Promise<SubscriptionMessageTemplateConfig>
|
||||
return config
|
||||
}
|
||||
|
||||
export function cacheSubscriptionMessageTemplateConfig(config: SubscriptionMessageTemplateConfig): SubscriptionMessageTemplateConfig {
|
||||
const normalized: SubscriptionMessageTemplateConfig = {
|
||||
templates: config.templates.filter((item) => item.templateId),
|
||||
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
|
||||
|
||||
@@ -20,4 +20,16 @@ API_BASE_URL=https://focus.richarjiang.com/
|
||||
# Server
|
||||
PORT=3000
|
||||
|
||||
WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED=antYfc85gvwImFZ9kM4UiqMOywJxbqFVgKHLH3NikII
|
||||
WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED=antYfc85gvwImFZ9kM4UiqMOywJxbqFVgKHLH3NikII
|
||||
|
||||
# COS upload
|
||||
COS_SECRET_ID=AKIDwwulT3ub9f9bxFVdihcP4Z1S6qivMxmu
|
||||
COS_SECRET_KEY=S1rrw0CY1fRQj7X7fCpjryAMwgel6drG
|
||||
COS_BUCKET=plates-1251306435
|
||||
COS_REGION=ap-guangzhou
|
||||
COS_UPLOAD_ROLE_ARN=qcs::cam::uin/649581473:roleName/MpPilatesCosUploadRole
|
||||
COS_APP_ID=1251306435
|
||||
COS_PUBLIC_BASE_URL=https://plates-1251306435.cos.ap-guangzhou.myqcloud.com
|
||||
COS_UPLOAD_PREFIX=mp/studio
|
||||
COS_UPLOAD_DURATION_SECONDS=1800
|
||||
COS_UPLOAD_ROLE_SESSION_NAME=mp-pilates-studio-upload
|
||||
|
||||
13
packages/server/.env.example
Normal file
13
packages/server/.env.example
Normal file
@@ -0,0 +1,13 @@
|
||||
DATABASE_URL=mysql://user:password@127.0.0.1:3306/mp_pilates
|
||||
JWT_SECRET=change-me
|
||||
WX_APPID=your-wechat-appid
|
||||
WX_SECRET=your-wechat-secret
|
||||
|
||||
# COS upload
|
||||
COS_SECRET_ID=your-cos-secret-id
|
||||
COS_SECRET_KEY=your-cos-secret-key
|
||||
COS_BUCKET=plates-1251306435
|
||||
COS_REGION=ap-guangzhou
|
||||
COS_PUBLIC_BASE_URL=https://plates-1251306435.cos.ap-guangzhou.myqcloud.com
|
||||
COS_UPLOAD_PREFIX=mp/studio
|
||||
COS_UPLOAD_DURATION_SECONDS=1800
|
||||
@@ -13,6 +13,7 @@
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:seed": "ts-node prisma/seed.ts",
|
||||
"studio:seed-gallery": "ts-node prisma/update-studio-gallery.ts",
|
||||
"lint": "eslint \"{src,test}/**/*.ts\""
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `memberships` ADD COLUMN `total_times` INTEGER NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `orders` ADD COLUMN `membership_id` VARCHAR(191) NULL;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX `memberships_user_id_card_type_id_idx` ON `memberships`(`user_id`, `card_type_id`);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX `orders_membership_id_idx` ON `orders`(`membership_id`);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `orders` ADD CONSTRAINT `orders_membership_id_fkey` FOREIGN KEY (`membership_id`) REFERENCES `memberships`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- Backfill purchased-times snapshot for existing times/trial cards
|
||||
UPDATE `memberships` `m`
|
||||
INNER JOIN `card_types` `ct` ON `ct`.`id` = `m`.`card_type_id`
|
||||
SET `m`.`total_times` = `ct`.`total_times`
|
||||
WHERE `ct`.`total_times` IS NOT NULL AND `m`.`total_times` IS NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `users` ADD COLUMN `last_login_at` DATETIME(3) NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `bookings` ADD COLUMN `membership_times_deducted` BOOLEAN NULL;
|
||||
@@ -0,0 +1,27 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE `lesson_supplements` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`user_id` VARCHAR(191) NOT NULL,
|
||||
`request_id` VARCHAR(80) NOT NULL,
|
||||
`quantity` INTEGER NOT NULL,
|
||||
`membership_id` VARCHAR(191) NULL,
|
||||
`deducted_times` INTEGER NOT NULL DEFAULT 0,
|
||||
`card_name` VARCHAR(191) NULL,
|
||||
`remark` VARCHAR(200) NULL,
|
||||
`operator_id` VARCHAR(191) NOT NULL,
|
||||
`operator_name` VARCHAR(191) NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`revoked_at` DATETIME(3) NULL,
|
||||
`revoked_by` VARCHAR(191) NULL,
|
||||
|
||||
INDEX `lesson_supplements_user_id_revoked_at_created_at_idx`(`user_id`, `revoked_at`, `created_at`),
|
||||
UNIQUE INDEX `lesson_supplements_user_id_request_id_key`(`user_id`, `request_id`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `lesson_supplements` ADD CONSTRAINT `lesson_supplements_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `lesson_supplements` ADD CONSTRAINT `lesson_supplements_membership_id_fkey` FOREIGN KEY (`membership_id`) REFERENCES `memberships`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
@@ -63,6 +63,12 @@ enum FlashSaleOrderStatus {
|
||||
EXPIRED
|
||||
}
|
||||
|
||||
enum InviteReferralStatus {
|
||||
REGISTERED
|
||||
TRIAL_PURCHASED
|
||||
QUALIFIED
|
||||
}
|
||||
|
||||
// ===== Models =====
|
||||
|
||||
model User {
|
||||
@@ -74,14 +80,19 @@ model User {
|
||||
avatarUrl String? @map("avatar_url")
|
||||
role UserRole @default(MEMBER)
|
||||
adminBookingSubscriptionCount Int @default(0) @map("admin_booking_subscription_count")
|
||||
lastLoginAt DateTime? @map("last_login_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
lessonSupplements LessonSupplement[]
|
||||
memberships Membership[]
|
||||
bookings Booking[]
|
||||
orders Order[]
|
||||
flashSaleOrders FlashSaleOrder[]
|
||||
subscriptionMessageConsents SubscriptionMessageConsent[]
|
||||
sentInviteReferrals InviteReferral[] @relation("InviteReferralInviter")
|
||||
receivedInviteReferral InviteReferral[] @relation("InviteReferralInvitee")
|
||||
inviteRewardGrants InviteRewardGrant[] @relation("InviteRewardGrantInviter")
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -120,6 +131,7 @@ model CardType {
|
||||
price Decimal @db.Decimal(10, 0)
|
||||
originalPrice Decimal? @map("original_price") @db.Decimal(10, 0)
|
||||
description String?
|
||||
coverUrl String? @map("cover_url")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
@@ -137,17 +149,22 @@ model Membership {
|
||||
userId String @map("user_id")
|
||||
cardTypeId String @map("card_type_id")
|
||||
remainingTimes Int? @map("remaining_times")
|
||||
totalTimes Int? @map("total_times")
|
||||
startDate DateTime @map("start_date")
|
||||
expireDate DateTime @map("expire_date")
|
||||
status MembershipStatus @default(ACTIVE)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
lessonSupplements LessonSupplement[]
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
cardType CardType @relation(fields: [cardTypeId], references: [id])
|
||||
bookings Booking[]
|
||||
orders Order[]
|
||||
inviteRewardGrants InviteRewardGrant[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([userId, cardTypeId])
|
||||
@@index([status])
|
||||
@@map("memberships")
|
||||
}
|
||||
@@ -190,21 +207,23 @@ model TimeSlot {
|
||||
}
|
||||
|
||||
model Booking {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
timeSlotId String @map("time_slot_id")
|
||||
membershipId String @map("membership_id")
|
||||
status BookingStatus @default(PENDING_CONFIRMATION)
|
||||
cancelledAt DateTime? @map("cancelled_at")
|
||||
confirmedAt DateTime? @map("confirmed_at")
|
||||
completedAt DateTime? @map("completed_at")
|
||||
operatorId String? @map("operator_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
timeSlotId String @map("time_slot_id")
|
||||
membershipId String @map("membership_id")
|
||||
membershipTimesDeducted Boolean? @map("membership_times_deducted")
|
||||
status BookingStatus @default(PENDING_CONFIRMATION)
|
||||
cancelledAt DateTime? @map("cancelled_at")
|
||||
confirmedAt DateTime? @map("confirmed_at")
|
||||
completedAt DateTime? @map("completed_at")
|
||||
operatorId String? @map("operator_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
timeSlot TimeSlot @relation(fields: [timeSlotId], references: [id])
|
||||
membership Membership @relation(fields: [membershipId], references: [id])
|
||||
qualifiedInviteReferrals InviteReferral[]
|
||||
|
||||
statusHistory BookingStatusHistory[]
|
||||
|
||||
@@ -233,6 +252,7 @@ model Order {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
cardTypeId String @map("card_type_id")
|
||||
membershipId String? @map("membership_id")
|
||||
orderNo String @unique @map("order_no")
|
||||
amount Decimal @db.Decimal(10, 0)
|
||||
status OrderStatus @default(PENDING)
|
||||
@@ -244,13 +264,57 @@ model Order {
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
cardType CardType @relation(fields: [cardTypeId], references: [id])
|
||||
membership Membership? @relation(fields: [membershipId], references: [id])
|
||||
flashSaleOrder FlashSaleOrder?
|
||||
inviteReferrals InviteReferral[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([status])
|
||||
@@index([membershipId])
|
||||
@@map("orders")
|
||||
}
|
||||
|
||||
model InviteReferral {
|
||||
id String @id @default(uuid())
|
||||
inviterId String @map("inviter_id")
|
||||
inviteeId String @unique @map("invitee_id")
|
||||
status InviteReferralStatus @default(REGISTERED)
|
||||
trialOrderId String? @unique @map("trial_order_id")
|
||||
qualifiedBookingId String? @unique @map("qualified_booking_id")
|
||||
invitedAt DateTime @default(now()) @map("invited_at")
|
||||
trialPurchasedAt DateTime? @map("trial_purchased_at")
|
||||
qualifiedAt DateTime? @map("qualified_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
inviter User @relation("InviteReferralInviter", fields: [inviterId], references: [id])
|
||||
invitee User @relation("InviteReferralInvitee", fields: [inviteeId], references: [id])
|
||||
trialOrder Order? @relation(fields: [trialOrderId], references: [id])
|
||||
qualifiedBooking Booking? @relation(fields: [qualifiedBookingId], references: [id])
|
||||
|
||||
@@unique([inviterId, inviteeId])
|
||||
@@index([inviterId, status])
|
||||
@@index([status])
|
||||
@@map("invite_referrals")
|
||||
}
|
||||
|
||||
model InviteRewardGrant {
|
||||
id String @id @default(uuid())
|
||||
inviterId String @map("inviter_id")
|
||||
membershipId String? @map("membership_id")
|
||||
qualifiedReferralCount Int @map("qualified_referral_count")
|
||||
rewardTimes Int @default(1) @map("reward_times")
|
||||
grantedAt DateTime @default(now()) @map("granted_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
inviter User @relation("InviteRewardGrantInviter", fields: [inviterId], references: [id])
|
||||
membership Membership? @relation(fields: [membershipId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([inviterId, grantedAt])
|
||||
@@map("invite_reward_grants")
|
||||
}
|
||||
|
||||
model StudioConfig {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
@@ -311,3 +375,27 @@ model FlashSaleOrder {
|
||||
@@index([status])
|
||||
@@map("flash_sale_orders")
|
||||
}
|
||||
|
||||
// Historical totals without fabricated scheduled dates.
|
||||
model LessonSupplement {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
requestId String @map("request_id") @db.VarChar(80)
|
||||
quantity Int
|
||||
membershipId String? @map("membership_id")
|
||||
deductedTimes Int @default(0) @map("deducted_times")
|
||||
cardName String? @map("card_name")
|
||||
remark String? @db.VarChar(200)
|
||||
operatorId String @map("operator_id")
|
||||
operatorName String @map("operator_name")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
revokedAt DateTime? @map("revoked_at")
|
||||
revokedBy String? @map("revoked_by")
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
membership Membership? @relation(fields: [membershipId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@unique([userId, requestId])
|
||||
@@index([userId, revokedAt, createdAt])
|
||||
@@map("lesson_supplements")
|
||||
}
|
||||
|
||||
39
packages/server/prisma/update-studio-gallery.ts
Normal file
39
packages/server/prisma/update-studio-gallery.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import { DEFAULT_STUDIO_GALLERY_PHOTOS } from '@mp-pilates/shared'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function main() {
|
||||
console.log('🖼️ Syncing studio gallery photos...')
|
||||
|
||||
const photos = [...DEFAULT_STUDIO_GALLERY_PHOTOS]
|
||||
const existing = await prisma.studioConfig.findFirst({ select: { id: true } })
|
||||
|
||||
if (existing) {
|
||||
await prisma.studioConfig.update({
|
||||
where: { id: existing.id },
|
||||
data: { photos },
|
||||
})
|
||||
console.log(` ✅ Updated existing studio config with ${photos.length} gallery images`)
|
||||
} else {
|
||||
await prisma.studioConfig.create({
|
||||
data: {
|
||||
name: '普拉提工作室',
|
||||
address: '请在管理后台设置地址',
|
||||
phone: '请在管理后台设置电话',
|
||||
cancelHoursLimit: 2,
|
||||
photos,
|
||||
},
|
||||
})
|
||||
console.log(` ✅ Created studio config with ${photos.length} gallery images`)
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error('❌ Studio gallery sync failed:', error)
|
||||
process.exit(1)
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
@@ -12,6 +12,7 @@ import { SchedulerModule } from './scheduler/scheduler.module'
|
||||
import { PaymentModule } from './payment/payment.module'
|
||||
import { AdminModule } from './admin/admin.module'
|
||||
import { FlashSaleModule } from './flash-sale/flash-sale.module'
|
||||
import { InviteModule } from './invite/invite.module'
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -30,6 +31,7 @@ import { FlashSaleModule } from './flash-sale/flash-sale.module'
|
||||
PaymentModule,
|
||||
AdminModule,
|
||||
FlashSaleModule,
|
||||
InviteModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
})
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing'
|
||||
import { JwtService } from '@nestjs/jwt'
|
||||
import { UnauthorizedException } from '@nestjs/common'
|
||||
import { UserRole } from '@mp-pilates/shared'
|
||||
import { MembershipStatus, UserRole } from '@mp-pilates/shared'
|
||||
import { ConfigService } from '@nestjs/config'
|
||||
import { AuthService, RANDOM_FN_TOKEN } from '../auth.service'
|
||||
import { WechatService } from '../wechat.service'
|
||||
import { PrismaService } from '../../prisma/prisma.service'
|
||||
import { InviteService } from '../../invite/invite.service'
|
||||
|
||||
// ─── Fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -22,6 +24,8 @@ const mockUser = {
|
||||
nickname: TEST_NICKNAME,
|
||||
avatarUrl: null,
|
||||
role: UserRole.MEMBER,
|
||||
adminBookingSubscriptionCount: 0,
|
||||
lastLoginAt: new Date('2024-01-01T00:00:00Z'),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
@@ -29,6 +33,9 @@ const mockUser = {
|
||||
// ─── Mocks ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockPrismaService = {
|
||||
membership: {
|
||||
count: jest.fn(),
|
||||
},
|
||||
user: {
|
||||
findUnique: jest.fn(),
|
||||
findUniqueOrThrow: jest.fn(),
|
||||
@@ -46,6 +53,14 @@ const mockJwtService = {
|
||||
sign: jest.fn(),
|
||||
}
|
||||
|
||||
const mockInviteService = {
|
||||
bindInviterToUser: jest.fn(),
|
||||
}
|
||||
|
||||
const mockConfigService = {
|
||||
get: jest.fn(),
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('AuthService', () => {
|
||||
@@ -58,6 +73,8 @@ describe('AuthService', () => {
|
||||
{ provide: PrismaService, useValue: mockPrismaService },
|
||||
{ provide: WechatService, useValue: mockWechatService },
|
||||
{ provide: JwtService, useValue: mockJwtService },
|
||||
{ provide: InviteService, useValue: mockInviteService },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
{ provide: RANDOM_FN_TOKEN, useValue: () => 0 }, // deterministic nickname
|
||||
],
|
||||
}).compile()
|
||||
@@ -66,6 +83,8 @@ describe('AuthService', () => {
|
||||
|
||||
jest.clearAllMocks()
|
||||
mockJwtService.sign.mockReturnValue(JWT_TOKEN)
|
||||
mockPrismaService.membership.count.mockResolvedValue(0)
|
||||
mockConfigService.get.mockReturnValue('tmpl-booking-confirmed')
|
||||
})
|
||||
|
||||
// ── login ──────────────────────────────────────────────────────────────────
|
||||
@@ -91,10 +110,35 @@ describe('AuthService', () => {
|
||||
where: { openid: OPENID },
|
||||
})
|
||||
expect(mockPrismaService.user.create).toHaveBeenCalledWith({
|
||||
data: { openid: OPENID, nickname: TEST_NICKNAME },
|
||||
data: {
|
||||
openid: OPENID,
|
||||
nickname: TEST_NICKNAME,
|
||||
adminBookingSubscriptionCount: 0,
|
||||
lastLoginAt: expect.any(Date),
|
||||
},
|
||||
})
|
||||
expect(result.user).toEqual(mockUser)
|
||||
expect(result.user).toEqual(expect.objectContaining({
|
||||
id: mockUser.id,
|
||||
phone: mockUser.phone,
|
||||
nickname: mockUser.nickname,
|
||||
avatarUrl: mockUser.avatarUrl,
|
||||
role: mockUser.role,
|
||||
activeMembershipCount: 0,
|
||||
inviteShareEligible: false,
|
||||
adminBookingSubscriptionCount: 0,
|
||||
}))
|
||||
expect(result.user.subscriptionMessageTemplates.templates).toHaveLength(2)
|
||||
expect(result.isNewUser).toBe(true)
|
||||
expect(mockInviteService.bindInviterToUser).toHaveBeenCalledWith(USER_ID, undefined)
|
||||
})
|
||||
|
||||
it('binds inviter for new users when inviterId is present', async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(null)
|
||||
mockPrismaService.user.create.mockResolvedValue(mockUser)
|
||||
|
||||
await authService.login(loginCode, undefined, undefined, 'inviter-001')
|
||||
|
||||
expect(mockInviteService.bindInviterToUser).toHaveBeenCalledWith(USER_ID, 'inviter-001')
|
||||
})
|
||||
|
||||
it('creates user with unionid when present', async () => {
|
||||
@@ -110,12 +154,19 @@ describe('AuthService', () => {
|
||||
await authService.login(loginCode)
|
||||
|
||||
expect(mockPrismaService.user.create).toHaveBeenCalledWith({
|
||||
data: { openid: OPENID, unionid, nickname: TEST_NICKNAME },
|
||||
data: {
|
||||
openid: OPENID,
|
||||
unionid,
|
||||
nickname: TEST_NICKNAME,
|
||||
adminBookingSubscriptionCount: 0,
|
||||
lastLoginAt: expect.any(Date),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('returns existing user when openid already exists', async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser)
|
||||
mockPrismaService.user.update.mockResolvedValue(mockUser)
|
||||
|
||||
const result = await authService.login(loginCode)
|
||||
|
||||
@@ -123,12 +174,21 @@ describe('AuthService', () => {
|
||||
where: { openid: OPENID },
|
||||
})
|
||||
expect(mockPrismaService.user.create).not.toHaveBeenCalled()
|
||||
expect(result.user).toEqual(mockUser)
|
||||
expect(mockPrismaService.user.update).toHaveBeenCalledWith({
|
||||
where: { id: USER_ID },
|
||||
data: { lastLoginAt: expect.any(Date) },
|
||||
})
|
||||
expect(result.user).toEqual(expect.objectContaining({
|
||||
id: mockUser.id,
|
||||
nickname: mockUser.nickname,
|
||||
role: mockUser.role,
|
||||
}))
|
||||
expect(result.isNewUser).toBe(false)
|
||||
})
|
||||
|
||||
it('returns a valid JWT token', async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser)
|
||||
mockPrismaService.user.update.mockResolvedValue(mockUser)
|
||||
|
||||
const result = await authService.login(loginCode)
|
||||
|
||||
@@ -141,14 +201,40 @@ describe('AuthService', () => {
|
||||
|
||||
it('returns both token and user in result', async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser)
|
||||
mockPrismaService.user.update.mockResolvedValue(mockUser)
|
||||
|
||||
const result = await authService.login(loginCode)
|
||||
|
||||
expect(result).toEqual({
|
||||
expect(result).toEqual(expect.objectContaining({
|
||||
token: JWT_TOKEN,
|
||||
user: mockUser,
|
||||
isNewUser: false,
|
||||
}))
|
||||
expect(result.user).toEqual(expect.objectContaining({
|
||||
id: mockUser.id,
|
||||
subscriptionMessageTemplates: {
|
||||
templates: [
|
||||
expect.objectContaining({ scene: 'BOOKING_CREATED' }),
|
||||
expect.objectContaining({ scene: 'ADMIN_BOOKING_CREATED' }),
|
||||
],
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
it('includes active membership count and invite eligibility in login response', async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser)
|
||||
mockPrismaService.user.update.mockResolvedValue(mockUser)
|
||||
mockPrismaService.membership.count.mockResolvedValue(2)
|
||||
|
||||
const result = await authService.login(loginCode)
|
||||
|
||||
expect(mockPrismaService.membership.count).toHaveBeenCalledWith({
|
||||
where: {
|
||||
userId: USER_ID,
|
||||
status: MembershipStatus.ACTIVE,
|
||||
},
|
||||
})
|
||||
expect(result.user.activeMembershipCount).toBe(2)
|
||||
expect(result.user.inviteShareEligible).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -166,6 +252,7 @@ describe('AuthService', () => {
|
||||
sessionKey: SESSION_KEY,
|
||||
})
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser)
|
||||
mockPrismaService.user.update.mockResolvedValue(mockUser)
|
||||
mockJwtService.sign.mockReturnValue(JWT_TOKEN)
|
||||
await authService.login('login_code')
|
||||
})
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Body,
|
||||
UseGuards,
|
||||
Request,
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
Request,
|
||||
UseGuards,
|
||||
} from '@nestjs/common'
|
||||
import type { UserProfileResponse } from '@mp-pilates/shared'
|
||||
import { AuthService } from './auth.service'
|
||||
import { LoginDto } from './dto/login.dto'
|
||||
import { BindPhoneDto } from './dto/bind-phone.dto'
|
||||
@@ -24,11 +25,12 @@ export class AuthController {
|
||||
|
||||
@Post('login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async login(@Body() loginDto: LoginDto): Promise<{ token: string; user: User; isNewUser: boolean }> {
|
||||
async login(@Body() loginDto: LoginDto): Promise<{ token: string; user: UserProfileResponse; isNewUser: boolean }> {
|
||||
return this.authService.login(
|
||||
loginDto.code,
|
||||
loginDto.nickname,
|
||||
loginDto.avatarUrl,
|
||||
loginDto.inviterId,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,16 +2,21 @@ import { Module } from '@nestjs/common'
|
||||
import { PassportModule } from '@nestjs/passport'
|
||||
import { JwtModule } from '@nestjs/jwt'
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config'
|
||||
import { MembershipModule } from '../membership/membership.module'
|
||||
import { AuthService, RANDOM_FN_TOKEN } from './auth.service'
|
||||
import { AuthController } from './auth.controller'
|
||||
import { WechatService } from './wechat.service'
|
||||
import { JwtStrategy } from './jwt.strategy'
|
||||
import { JwtAuthGuard } from './jwt-auth.guard'
|
||||
import { RolesGuard } from './roles.guard'
|
||||
import { InviteModule } from '../invite/invite.module'
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule,
|
||||
InviteModule,
|
||||
ConfigModule,
|
||||
MembershipModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'
|
||||
import { JwtService } from '@nestjs/jwt'
|
||||
import { User } from '@prisma/client'
|
||||
import { UserRole } from '@mp-pilates/shared'
|
||||
import {
|
||||
MembershipStatus,
|
||||
SubscriptionMessageScene,
|
||||
type SubscriptionMessageTemplate,
|
||||
type SubscriptionMessageTemplateConfig,
|
||||
type UserProfileResponse,
|
||||
UserRole,
|
||||
} from '@mp-pilates/shared'
|
||||
import { ConfigService } from '@nestjs/config'
|
||||
import { PrismaService } from '../prisma/prisma.service'
|
||||
import { WechatService } from './wechat.service'
|
||||
import { InviteService } from '../invite/invite.service'
|
||||
|
||||
export interface LoginResult {
|
||||
token: string
|
||||
user: User
|
||||
user: UserProfileResponse
|
||||
isNewUser: boolean
|
||||
}
|
||||
|
||||
@@ -55,13 +64,59 @@ export class AuthService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly wechatService: WechatService,
|
||||
private readonly inviteService: InviteService,
|
||||
private readonly configService: ConfigService,
|
||||
@Inject(RANDOM_FN_TOKEN) private readonly randomFn: () => number = Math.random,
|
||||
) {}
|
||||
|
||||
private buildSubscriptionTemplateConfig(): SubscriptionMessageTemplateConfig {
|
||||
const templates = [
|
||||
{
|
||||
templateId: this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED', ''),
|
||||
scene: SubscriptionMessageScene.BOOKING_CREATED,
|
||||
description: '购卡或预约时请求一次订阅,用于后续预约确认通知推送',
|
||||
usageTarget: 'consent' as const,
|
||||
},
|
||||
{
|
||||
templateId: this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED', ''),
|
||||
scene: SubscriptionMessageScene.ADMIN_BOOKING_CREATED,
|
||||
description: '管理员主动增加预约提醒次数,用于接收学员新预约通知',
|
||||
usageTarget: 'counter' as const,
|
||||
},
|
||||
] satisfies SubscriptionMessageTemplate[]
|
||||
|
||||
return {
|
||||
templates: templates.filter((item) => item.templateId),
|
||||
}
|
||||
}
|
||||
|
||||
private async mapLoginUser(user: User): Promise<UserProfileResponse> {
|
||||
const activeMembershipCount = await this.prisma.membership.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
status: MembershipStatus.ACTIVE,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
phone: user.phone,
|
||||
nickname: user.nickname,
|
||||
avatarUrl: user.avatarUrl,
|
||||
role: user.role as UserRole,
|
||||
activeMembershipCount,
|
||||
inviteShareEligible: activeMembershipCount > 0,
|
||||
adminBookingSubscriptionCount: user.adminBookingSubscriptionCount,
|
||||
subscriptionMessageTemplates: this.buildSubscriptionTemplateConfig(),
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
async login(
|
||||
code: string,
|
||||
nickname?: string,
|
||||
avatarUrl?: string,
|
||||
inviterId?: string,
|
||||
): Promise<LoginResult> {
|
||||
const { openid, unionid, sessionKey } =
|
||||
await this.wechatService.code2Session(code)
|
||||
@@ -71,37 +126,37 @@ export class AuthService {
|
||||
})
|
||||
|
||||
const isNewUser = existingUser === null
|
||||
const now = new Date()
|
||||
|
||||
const user =
|
||||
existingUser ??
|
||||
(await this.prisma.user.create({
|
||||
data: {
|
||||
openid,
|
||||
...(unionid !== undefined && { unionid }),
|
||||
nickname: nickname || generateDefaultNickname(this.randomFn),
|
||||
...(avatarUrl && { avatarUrl }),
|
||||
adminBookingSubscriptionCount: 0,
|
||||
},
|
||||
}))
|
||||
|
||||
// Update avatar for existing users if new avatar is provided
|
||||
if (existingUser && avatarUrl) {
|
||||
const updated = await this.prisma.user.update({
|
||||
where: { id: existingUser.id },
|
||||
data: { avatarUrl, ...(nickname && { nickname }) },
|
||||
})
|
||||
sessionKeyStore.set(updated.id, sessionKey)
|
||||
const payload: JwtPayload = { sub: updated.id, role: updated.role as UserRole }
|
||||
const token = this.jwtService.sign(payload)
|
||||
return { token, user: updated, isNewUser: false }
|
||||
}
|
||||
const user = isNewUser
|
||||
? await this.prisma.user.create({
|
||||
data: {
|
||||
openid,
|
||||
...(unionid !== undefined && { unionid }),
|
||||
nickname: nickname || generateDefaultNickname(this.randomFn),
|
||||
...(avatarUrl && { avatarUrl }),
|
||||
adminBookingSubscriptionCount: 0,
|
||||
lastLoginAt: now,
|
||||
},
|
||||
})
|
||||
: await this.prisma.user.update({
|
||||
where: { id: existingUser.id },
|
||||
data: {
|
||||
lastLoginAt: now,
|
||||
...(avatarUrl && { avatarUrl, ...(nickname && { nickname }) }),
|
||||
},
|
||||
})
|
||||
|
||||
sessionKeyStore.set(user.id, sessionKey)
|
||||
|
||||
if (isNewUser) {
|
||||
await this.inviteService.bindInviterToUser(user.id, inviterId)
|
||||
}
|
||||
|
||||
const payload: JwtPayload = { sub: user.id, role: user.role as UserRole }
|
||||
const token = this.jwtService.sign(payload)
|
||||
|
||||
return { token, user, isNewUser }
|
||||
return { token, user: await this.mapLoginUser(user), isNewUser }
|
||||
}
|
||||
|
||||
async bindPhone(
|
||||
|
||||
@@ -12,4 +12,8 @@ export class LoginDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
avatarUrl?: string
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
inviterId?: string
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { PrismaService } from '../../prisma/prisma.service'
|
||||
import { MembershipService } from '../../membership/membership.service'
|
||||
import { StudioService } from '../../studio/studio.service'
|
||||
import { SubscriptionMessageService } from '../../user/subscription-message.service'
|
||||
import { InviteService } from '../../invite/invite.service'
|
||||
|
||||
// ─── Fixtures ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -84,6 +85,13 @@ const mockDurationMembership = {
|
||||
cardType: mockDurationCardType,
|
||||
}
|
||||
|
||||
const mockLimitedDurationMembership = {
|
||||
...mockDurationMembership,
|
||||
id: 'mem-duration-limited-001',
|
||||
remainingTimes: 5,
|
||||
totalTimes: 10,
|
||||
}
|
||||
|
||||
const mockExpiredMembership = {
|
||||
...mockActiveMembership,
|
||||
id: 'mem-expired-001',
|
||||
@@ -101,6 +109,7 @@ const mockConfirmedBooking = {
|
||||
userId: MOCK_USER_ID,
|
||||
timeSlotId: MOCK_SLOT_ID,
|
||||
membershipId: MOCK_MEMBERSHIP_ID,
|
||||
membershipTimesDeducted: true,
|
||||
status: BookingStatus.CONFIRMED,
|
||||
cancelledAt: null,
|
||||
createdAt: new Date(),
|
||||
@@ -128,6 +137,8 @@ function buildTxMock(overrides: Record<string, unknown> = {}) {
|
||||
timeSlot: {
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
create: jest.fn(),
|
||||
updateMany: jest.fn(),
|
||||
},
|
||||
booking: {
|
||||
findUnique: jest.fn(),
|
||||
@@ -139,6 +150,9 @@ function buildTxMock(overrides: Record<string, unknown> = {}) {
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
user: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
bookingStatusHistory: {
|
||||
create: jest.fn(),
|
||||
},
|
||||
@@ -153,6 +167,7 @@ describe('BookingService', () => {
|
||||
let prisma: jest.Mocked<PrismaService>
|
||||
let studioService: jest.Mocked<StudioService>
|
||||
let subscriptionMessageService: { sendBookingConfirmedMessage: jest.Mock; sendAdminBookingCreatedMessage: jest.Mock }
|
||||
let inviteService: { recordQualifiedTrialBooking: jest.Mock }
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
@@ -171,6 +186,7 @@ describe('BookingService', () => {
|
||||
},
|
||||
timeSlot: {
|
||||
findUnique: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
membership: {
|
||||
@@ -204,6 +220,12 @@ describe('BookingService', () => {
|
||||
sendAdminBookingCreatedMessage: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: InviteService,
|
||||
useValue: {
|
||||
recordQualifiedTrialBooking: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile()
|
||||
|
||||
@@ -211,6 +233,7 @@ describe('BookingService', () => {
|
||||
prisma = module.get(PrismaService) as jest.Mocked<PrismaService>
|
||||
studioService = module.get(StudioService) as jest.Mocked<StudioService>
|
||||
subscriptionMessageService = module.get(SubscriptionMessageService)
|
||||
inviteService = module.get(InviteService)
|
||||
})
|
||||
|
||||
afterEach(() => jest.clearAllMocks())
|
||||
@@ -259,6 +282,133 @@ describe('BookingService', () => {
|
||||
courseName: 'FocusCore Pilates',
|
||||
bookingEndTime: '2099-12-31 10:00',
|
||||
})
|
||||
expect(tx.booking.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ membershipTimesDeducted: true }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('deducts a count-limited DURATION membership on confirmation', async () => {
|
||||
const tx = buildTxMock()
|
||||
tx.booking.findUnique.mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
membershipId: mockLimitedDurationMembership.id,
|
||||
status: BookingStatus.PENDING_CONFIRMATION,
|
||||
timeSlot: mockOpenSlot,
|
||||
membership: mockLimitedDurationMembership,
|
||||
})
|
||||
tx.booking.update.mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
membershipId: mockLimitedDurationMembership.id,
|
||||
status: BookingStatus.CONFIRMED,
|
||||
})
|
||||
tx.timeSlot.update.mockResolvedValue({ ...mockOpenSlot, bookedCount: 1 })
|
||||
tx.membership.update.mockResolvedValue({
|
||||
...mockLimitedDurationMembership,
|
||||
remainingTimes: 4,
|
||||
})
|
||||
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
membershipId: mockLimitedDurationMembership.id,
|
||||
status: BookingStatus.CONFIRMED,
|
||||
timeSlot: mockOpenSlot,
|
||||
membership: mockLimitedDurationMembership,
|
||||
})
|
||||
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ openid: 'openid-001' })
|
||||
studioService.getInfo.mockResolvedValue(mockStudioConfig)
|
||||
subscriptionMessageService.sendBookingConfirmedMessage.mockResolvedValue(true)
|
||||
|
||||
await service.confirmBooking(MOCK_BOOKING_ID, 'admin-001')
|
||||
|
||||
expect(tx.membership.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: mockLimitedDurationMembership.id },
|
||||
data: { remainingTimes: 4, status: MembershipStatus.ACTIVE },
|
||||
}),
|
||||
)
|
||||
expect(tx.booking.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ membershipTimesDeducted: true }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('records no deduction for an unlimited DURATION membership on confirmation', async () => {
|
||||
const tx = buildTxMock()
|
||||
tx.booking.findUnique.mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
membershipId: mockDurationMembership.id,
|
||||
status: BookingStatus.PENDING_CONFIRMATION,
|
||||
timeSlot: mockOpenSlot,
|
||||
membership: mockDurationMembership,
|
||||
})
|
||||
tx.booking.update.mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
membershipId: mockDurationMembership.id,
|
||||
membershipTimesDeducted: false,
|
||||
status: BookingStatus.CONFIRMED,
|
||||
})
|
||||
tx.timeSlot.update.mockResolvedValue({ ...mockOpenSlot, bookedCount: 1 })
|
||||
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
membershipId: mockDurationMembership.id,
|
||||
membershipTimesDeducted: false,
|
||||
status: BookingStatus.CONFIRMED,
|
||||
timeSlot: mockOpenSlot,
|
||||
membership: mockDurationMembership,
|
||||
})
|
||||
|
||||
await service.confirmBooking(MOCK_BOOKING_ID, 'admin-001')
|
||||
|
||||
expect(tx.membership.update).not.toHaveBeenCalled()
|
||||
expect(tx.booking.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ membershipTimesDeducted: false }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('completeBooking', () => {
|
||||
it('records qualified trial booking after completion', async () => {
|
||||
const tx = buildTxMock({
|
||||
bookingStatusHistory: { create: jest.fn() },
|
||||
})
|
||||
|
||||
tx.booking.findUnique.mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
status: BookingStatus.CONFIRMED,
|
||||
timeSlot: mockOpenSlot,
|
||||
})
|
||||
tx.booking.update.mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
status: BookingStatus.COMPLETED,
|
||||
completedAt: new Date('2099-12-31T11:00:00Z'),
|
||||
})
|
||||
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
status: BookingStatus.COMPLETED,
|
||||
completedAt: new Date('2099-12-31T11:00:00Z'),
|
||||
timeSlot: mockOpenSlot,
|
||||
membership: {
|
||||
...mockActiveMembership,
|
||||
cardType: {
|
||||
...mockTimesCardType,
|
||||
type: CardTypeCategory.TRIAL,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await service.completeBooking(MOCK_BOOKING_ID, 'admin-001')
|
||||
|
||||
expect(inviteService.recordQualifiedTrialBooking).toHaveBeenCalledWith(MOCK_BOOKING_ID)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -296,6 +446,7 @@ describe('BookingService', () => {
|
||||
userId: MOCK_USER_ID,
|
||||
timeSlotId: MOCK_SLOT_ID,
|
||||
membershipId: MOCK_MEMBERSHIP_ID,
|
||||
membershipTimesDeducted: false,
|
||||
status: BookingStatus.PENDING_CONFIRMATION,
|
||||
}),
|
||||
}),
|
||||
@@ -543,6 +694,7 @@ describe('BookingService', () => {
|
||||
where: { id: MOCK_BOOKING_ID },
|
||||
data: {
|
||||
membershipId: MOCK_MEMBERSHIP_ID,
|
||||
membershipTimesDeducted: false,
|
||||
status: BookingStatus.PENDING_CONFIRMATION,
|
||||
cancelledAt: null,
|
||||
confirmedAt: null,
|
||||
@@ -629,6 +781,100 @@ describe('BookingService', () => {
|
||||
expect(result.refunded).toBe(true)
|
||||
})
|
||||
|
||||
it('restores a count-limited DURATION membership when cancelled within the limit', async () => {
|
||||
const limitedDurationMembership = {
|
||||
...mockLimitedDurationMembership,
|
||||
remainingTimes: 4,
|
||||
}
|
||||
const bookingWithRelations = {
|
||||
...mockConfirmedBooking,
|
||||
membershipId: limitedDurationMembership.id,
|
||||
timeSlot: { ...futureSlot, bookedCount: 1 },
|
||||
membership: limitedDurationMembership,
|
||||
}
|
||||
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(bookingWithRelations)
|
||||
|
||||
const tx = buildTxMock()
|
||||
tx.booking.update.mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
membershipId: limitedDurationMembership.id,
|
||||
status: BookingStatus.CANCELLED,
|
||||
})
|
||||
tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 })
|
||||
tx.membership.update.mockResolvedValue({
|
||||
...limitedDurationMembership,
|
||||
remainingTimes: 5,
|
||||
})
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
|
||||
const result = await service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID)
|
||||
|
||||
expect(tx.membership.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: limitedDurationMembership.id },
|
||||
data: expect.objectContaining({
|
||||
remainingTimes: 5,
|
||||
status: MembershipStatus.ACTIVE,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(result.refunded).toBe(true)
|
||||
})
|
||||
|
||||
it('does not restore an unlimited DURATION membership when cancelled within the limit', async () => {
|
||||
const bookingWithRelations = {
|
||||
...mockConfirmedBooking,
|
||||
membershipId: mockDurationMembership.id,
|
||||
membershipTimesDeducted: false,
|
||||
timeSlot: { ...futureSlot, bookedCount: 1 },
|
||||
membership: mockDurationMembership,
|
||||
}
|
||||
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(bookingWithRelations)
|
||||
|
||||
const tx = buildTxMock()
|
||||
tx.booking.update.mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
membershipId: mockDurationMembership.id,
|
||||
status: BookingStatus.CANCELLED,
|
||||
})
|
||||
tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 })
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
|
||||
const result = await service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID)
|
||||
|
||||
expect(tx.membership.update).not.toHaveBeenCalled()
|
||||
expect(result.refunded).toBe(false)
|
||||
})
|
||||
|
||||
it('does not refund an originally unlimited membership after it is changed to counted', async () => {
|
||||
const bookingWithRelations = {
|
||||
...mockConfirmedBooking,
|
||||
membershipId: mockLimitedDurationMembership.id,
|
||||
membershipTimesDeducted: false,
|
||||
timeSlot: { ...futureSlot, bookedCount: 1 },
|
||||
membership: {
|
||||
...mockLimitedDurationMembership,
|
||||
remainingTimes: 10,
|
||||
},
|
||||
}
|
||||
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(bookingWithRelations)
|
||||
|
||||
const tx = buildTxMock()
|
||||
tx.booking.update.mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
membershipId: mockLimitedDurationMembership.id,
|
||||
membershipTimesDeducted: false,
|
||||
status: BookingStatus.CANCELLED,
|
||||
})
|
||||
tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 })
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
|
||||
const result = await service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID)
|
||||
|
||||
expect(tx.membership.update).not.toHaveBeenCalled()
|
||||
expect(result.refunded).toBe(false)
|
||||
})
|
||||
|
||||
it('cancels booking past limit: does NOT refund membership', async () => {
|
||||
const bookingWithImminent = {
|
||||
...mockConfirmedBooking,
|
||||
@@ -856,4 +1102,484 @@ describe('BookingService', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTeachingScheduleByDate', () => {
|
||||
it('returns sorted slots with active students only', async () => {
|
||||
;(prisma.timeSlot.findMany as jest.Mock).mockResolvedValue([
|
||||
{
|
||||
id: 'slot-02',
|
||||
startTime: '11:00',
|
||||
endTime: '12:00',
|
||||
bookedCount: 1,
|
||||
capacity: 1,
|
||||
bookings: [
|
||||
{
|
||||
id: 'booking-02',
|
||||
status: BookingStatus.CONFIRMED,
|
||||
createdAt: new Date('2026-04-19T01:00:00Z'),
|
||||
user: { id: 'user-02', nickname: '李四', phone: '13800000000' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'slot-01',
|
||||
startTime: '09:00',
|
||||
endTime: '10:00',
|
||||
bookedCount: 2,
|
||||
capacity: 2,
|
||||
bookings: [
|
||||
{
|
||||
id: 'booking-01',
|
||||
status: BookingStatus.PENDING_CONFIRMATION,
|
||||
createdAt: new Date('2026-04-19T00:00:00Z'),
|
||||
user: { id: 'user-01', nickname: '张三', phone: null },
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const result = await service.getTeachingScheduleByDate('2026-04-19')
|
||||
|
||||
expect(prisma.timeSlot.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
bookings: {
|
||||
some: {
|
||||
status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] },
|
||||
},
|
||||
},
|
||||
}),
|
||||
orderBy: [
|
||||
{ startTime: 'asc' },
|
||||
{ endTime: 'asc' },
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(result).toEqual([
|
||||
{
|
||||
slotId: 'slot-01',
|
||||
date: '2026-04-19',
|
||||
startTime: '09:00',
|
||||
endTime: '10:00',
|
||||
bookedCount: 2,
|
||||
capacity: 2,
|
||||
students: [
|
||||
{
|
||||
bookingId: 'booking-01',
|
||||
userId: 'user-01',
|
||||
nickname: '张三',
|
||||
phone: null,
|
||||
status: BookingStatus.PENDING_CONFIRMATION,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slotId: 'slot-02',
|
||||
date: '2026-04-19',
|
||||
startTime: '11:00',
|
||||
endTime: '12:00',
|
||||
bookedCount: 1,
|
||||
capacity: 1,
|
||||
students: [
|
||||
{
|
||||
bookingId: 'booking-02',
|
||||
userId: 'user-02',
|
||||
nickname: '李四',
|
||||
phone: '13800000000',
|
||||
status: BookingStatus.CONFIRMED,
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects invalid date input', async () => {
|
||||
await expect(service.getTeachingScheduleByDate('invalid-date')).rejects.toThrow(
|
||||
BadRequestException,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('adminArrangeBooking', () => {
|
||||
const MOCK_ADMIN_ID = 'admin-001'
|
||||
const dto = {
|
||||
userId: MOCK_USER_ID,
|
||||
timeSlotId: MOCK_SLOT_ID,
|
||||
membershipId: MOCK_MEMBERSHIP_ID,
|
||||
}
|
||||
|
||||
const mockTrialCardType = {
|
||||
...mockTimesCardType,
|
||||
id: 'ct-trial-001',
|
||||
name: '体验卡',
|
||||
type: CardTypeCategory.TRIAL,
|
||||
totalTimes: 1,
|
||||
}
|
||||
|
||||
const mockTrialMembership = {
|
||||
...mockActiveMembership,
|
||||
id: 'mem-trial-001',
|
||||
cardTypeId: mockTrialCardType.id,
|
||||
remainingTimes: 1,
|
||||
cardType: mockTrialCardType,
|
||||
}
|
||||
|
||||
function stubArrangeSuccess(
|
||||
tx: ReturnType<typeof buildTxMock>,
|
||||
options?: {
|
||||
membership?: typeof mockActiveMembership
|
||||
| typeof mockDurationMembership
|
||||
| typeof mockLimitedDurationMembership
|
||||
| typeof mockTrialMembership
|
||||
slot?: typeof mockOpenSlot
|
||||
existing?: typeof mockConfirmedBooking | null
|
||||
},
|
||||
) {
|
||||
const membership = options?.membership ?? mockActiveMembership
|
||||
const slot = options?.slot ?? mockOpenSlot
|
||||
const existing = options?.existing ?? null
|
||||
const arranged = {
|
||||
...mockConfirmedBooking,
|
||||
membershipId: membership.id,
|
||||
status: BookingStatus.CONFIRMED,
|
||||
confirmedAt: new Date(),
|
||||
operatorId: MOCK_ADMIN_ID,
|
||||
}
|
||||
|
||||
tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID })
|
||||
tx.timeSlot.findUnique
|
||||
.mockResolvedValueOnce(slot)
|
||||
.mockResolvedValueOnce({ ...slot, bookedCount: slot.bookedCount + 1 })
|
||||
tx.booking.findUnique.mockResolvedValue(existing)
|
||||
tx.membership.findUnique.mockResolvedValue(membership)
|
||||
tx.booking.create.mockResolvedValue(arranged)
|
||||
tx.booking.update.mockResolvedValue(arranged)
|
||||
tx.timeSlot.updateMany.mockResolvedValue({ count: 1 })
|
||||
tx.timeSlot.update.mockResolvedValue({ ...slot, bookedCount: slot.bookedCount + 1 })
|
||||
tx.membership.update.mockResolvedValue({
|
||||
...membership,
|
||||
remainingTimes: membership.remainingTimes == null ? null : membership.remainingTimes - 1,
|
||||
})
|
||||
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
|
||||
...arranged,
|
||||
timeSlot: slot,
|
||||
membership,
|
||||
})
|
||||
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ openid: 'openid-001' })
|
||||
studioService.getInfo.mockResolvedValue({
|
||||
...mockStudioConfig,
|
||||
name: 'FocusCore Pilates',
|
||||
})
|
||||
subscriptionMessageService.sendBookingConfirmedMessage.mockResolvedValue(true)
|
||||
|
||||
return arranged
|
||||
}
|
||||
|
||||
it('creates a confirmed times-card booking and deducts one session', async () => {
|
||||
const tx = buildTxMock()
|
||||
stubArrangeSuccess(tx)
|
||||
|
||||
const result = await service.adminArrangeBooking(MOCK_ADMIN_ID, dto)
|
||||
|
||||
expect(tx.booking.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
userId: MOCK_USER_ID,
|
||||
timeSlotId: MOCK_SLOT_ID,
|
||||
membershipId: MOCK_MEMBERSHIP_ID,
|
||||
membershipTimesDeducted: true,
|
||||
status: BookingStatus.CONFIRMED,
|
||||
operatorId: MOCK_ADMIN_ID,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(tx.membership.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ remainingTimes: 4, status: MembershipStatus.ACTIVE }),
|
||||
}),
|
||||
)
|
||||
expect(tx.timeSlot.updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
id: MOCK_SLOT_ID,
|
||||
status: TimeSlotStatus.OPEN,
|
||||
bookedCount: { lt: mockOpenSlot.capacity },
|
||||
}),
|
||||
data: { bookedCount: { increment: 1 } },
|
||||
}),
|
||||
)
|
||||
expect(tx.bookingStatusHistory.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
toStatus: BookingStatus.CONFIRMED,
|
||||
remark: '老师代为安排',
|
||||
operatorId: MOCK_ADMIN_ID,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(subscriptionMessageService.sendBookingConfirmedMessage).toHaveBeenCalled()
|
||||
expect(subscriptionMessageService.sendAdminBookingCreatedMessage).not.toHaveBeenCalled()
|
||||
expect(result.status).toBe(BookingStatus.CONFIRMED)
|
||||
})
|
||||
|
||||
it('does not deduct remaining times for unlimited duration cards', async () => {
|
||||
const tx = buildTxMock()
|
||||
stubArrangeSuccess(tx, { membership: mockDurationMembership })
|
||||
|
||||
await service.adminArrangeBooking(MOCK_ADMIN_ID, {
|
||||
...dto,
|
||||
membershipId: mockDurationMembership.id,
|
||||
})
|
||||
|
||||
expect(tx.membership.update).not.toHaveBeenCalled()
|
||||
expect(tx.booking.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ membershipTimesDeducted: false }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('deducts remaining times for count-limited duration cards', async () => {
|
||||
const tx = buildTxMock()
|
||||
stubArrangeSuccess(tx, { membership: mockLimitedDurationMembership })
|
||||
|
||||
await service.adminArrangeBooking(MOCK_ADMIN_ID, {
|
||||
...dto,
|
||||
membershipId: mockLimitedDurationMembership.id,
|
||||
})
|
||||
|
||||
expect(tx.membership.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: mockLimitedDurationMembership.id },
|
||||
data: { remainingTimes: 4, status: MembershipStatus.ACTIVE },
|
||||
}),
|
||||
)
|
||||
expect(tx.booking.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ membershipTimesDeducted: true }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('deducts a trial card session', async () => {
|
||||
const tx = buildTxMock()
|
||||
stubArrangeSuccess(tx, { membership: mockTrialMembership })
|
||||
|
||||
await service.adminArrangeBooking(MOCK_ADMIN_ID, {
|
||||
...dto,
|
||||
membershipId: mockTrialMembership.id,
|
||||
})
|
||||
|
||||
expect(tx.membership.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
remainingTimes: 0,
|
||||
status: MembershipStatus.USED_UP,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects when the times card has no remaining sessions', async () => {
|
||||
const tx = buildTxMock()
|
||||
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
||||
tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID })
|
||||
tx.booking.findUnique.mockResolvedValue(null)
|
||||
tx.membership.findUnique.mockResolvedValue(mockMembershipNoTimes)
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
|
||||
await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
)
|
||||
expect(tx.booking.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects when a duration card has expired', async () => {
|
||||
const tx = buildTxMock()
|
||||
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
||||
tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID })
|
||||
tx.booking.findUnique.mockResolvedValue(null)
|
||||
tx.membership.findUnique.mockResolvedValue({
|
||||
...mockDurationMembership,
|
||||
expireDate: new Date('2020-01-01'),
|
||||
})
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
|
||||
await expect(
|
||||
service.adminArrangeBooking(MOCK_ADMIN_ID, {
|
||||
...dto,
|
||||
membershipId: mockDurationMembership.id,
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException)
|
||||
})
|
||||
|
||||
it('rejects when the time slot is full', async () => {
|
||||
const tx = buildTxMock()
|
||||
tx.timeSlot.findUnique.mockResolvedValue(mockFullSlot)
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
|
||||
await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
)
|
||||
expect(tx.membership.findUnique).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects duplicate active bookings for the same slot', async () => {
|
||||
const tx = buildTxMock()
|
||||
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
||||
tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID })
|
||||
tx.booking.findUnique.mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
status: BookingStatus.CONFIRMED,
|
||||
})
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
|
||||
await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow(
|
||||
ConflictException,
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects arranging a past time slot', async () => {
|
||||
const tx = buildTxMock()
|
||||
tx.timeSlot.findUnique.mockResolvedValue({
|
||||
...mockOpenSlot,
|
||||
date: new Date('2020-01-01T00:00:00Z'),
|
||||
startTime: '09:00',
|
||||
})
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
|
||||
await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
)
|
||||
})
|
||||
|
||||
it('revives a cancelled booking instead of creating a new row', async () => {
|
||||
const tx = buildTxMock()
|
||||
const cancelled = {
|
||||
...mockConfirmedBooking,
|
||||
status: BookingStatus.CANCELLED,
|
||||
}
|
||||
stubArrangeSuccess(tx, { existing: cancelled })
|
||||
|
||||
await service.adminArrangeBooking(MOCK_ADMIN_ID, dto)
|
||||
|
||||
expect(tx.booking.create).not.toHaveBeenCalled()
|
||||
expect(tx.booking.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: cancelled.id },
|
||||
data: expect.objectContaining({
|
||||
status: BookingStatus.CONFIRMED,
|
||||
operatorId: MOCK_ADMIN_ID,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(tx.timeSlot.updateMany).toHaveBeenCalled()
|
||||
expect(tx.membership.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ remainingTimes: 4 }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects when the member does not exist', async () => {
|
||||
const tx = buildTxMock()
|
||||
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
||||
tx.user.findUnique.mockResolvedValue(null)
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
|
||||
await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow(
|
||||
NotFoundException,
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects when the membership belongs to another member', async () => {
|
||||
const tx = buildTxMock()
|
||||
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
||||
tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID })
|
||||
tx.booking.findUnique.mockResolvedValue(null)
|
||||
tx.membership.findUnique.mockResolvedValue({
|
||||
...mockActiveMembership,
|
||||
userId: 'other-user',
|
||||
})
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
|
||||
await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow(
|
||||
ForbiddenException,
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects an expired times card even if remaining sessions exist', async () => {
|
||||
const tx = buildTxMock()
|
||||
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
||||
tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID })
|
||||
tx.booking.findUnique.mockResolvedValue(null)
|
||||
tx.membership.findUnique.mockResolvedValue({
|
||||
...mockActiveMembership,
|
||||
expireDate: new Date('2020-01-01'),
|
||||
})
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
|
||||
await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
)
|
||||
expect(tx.timeSlot.updateMany).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects when occupancy update races and the slot is already full', async () => {
|
||||
const tx = buildTxMock()
|
||||
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
||||
tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID })
|
||||
tx.booking.findUnique.mockResolvedValue(null)
|
||||
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
|
||||
tx.timeSlot.updateMany.mockResolvedValue({ count: 0 })
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
|
||||
await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
)
|
||||
expect(tx.booking.create).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
describe('getPracticeActivity', () => {
|
||||
afterEach(() => jest.restoreAllMocks())
|
||||
|
||||
it('uses China today across UTC midnight and counts scheduled dates without pagination', async () => {
|
||||
jest.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-09-07T16:01:00Z'))
|
||||
;(prisma.booking.findMany as jest.Mock).mockResolvedValue([
|
||||
{ timeSlot: { date: new Date('2026-08-10T00:00:00Z') } },
|
||||
{ timeSlot: { date: new Date('2026-09-08T00:00:00Z') } },
|
||||
{ timeSlot: { date: new Date('2026-09-08T00:00:00Z') } },
|
||||
])
|
||||
const result = await service.getPracticeActivity(MOCK_USER_ID)
|
||||
expect(result.days).toHaveLength(30)
|
||||
expect(result.days[0]).toEqual({ date: '2026-08-10', count: 1 })
|
||||
expect(result.days[29]).toEqual({ date: '2026-09-08', count: 2 })
|
||||
expect(result.days[1]).toEqual({ date: '2026-08-11', count: 0 })
|
||||
expect(prisma.booking.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
userId: MOCK_USER_ID,
|
||||
status: BookingStatus.COMPLETED,
|
||||
timeSlot: { date: {
|
||||
gte: new Date('2026-08-10T00:00:00Z'),
|
||||
lt: new Date('2026-09-09T00:00:00Z'),
|
||||
} },
|
||||
},
|
||||
select: { timeSlot: { select: { date: true } } },
|
||||
})
|
||||
})
|
||||
|
||||
it('returns every day with zero counts across a leap-year boundary', async () => {
|
||||
jest.spyOn(Date, 'now').mockReturnValue(Date.parse('2024-03-01T01:00:00Z'))
|
||||
;(prisma.booking.findMany as jest.Mock).mockResolvedValue([])
|
||||
const result = await service.getPracticeActivity(MOCK_USER_ID)
|
||||
expect(result.days).toHaveLength(30)
|
||||
expect(result.days[0].date).toBe('2024-02-01')
|
||||
expect(result.days[28].date).toBe('2024-02-29')
|
||||
expect(result.days[29].date).toBe('2024-03-01')
|
||||
expect(result.days.every(day => day.count === 0)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
@@ -15,6 +16,7 @@ import { Roles } from '../auth/roles.decorator'
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator'
|
||||
import { BookingService } from './booking.service'
|
||||
import { CreateBookingDto } from './dto/create-booking.dto'
|
||||
import { AdminArrangeBookingDto } from './dto/admin-arrange-booking.dto'
|
||||
|
||||
@Controller()
|
||||
export class BookingController {
|
||||
@@ -40,6 +42,12 @@ export class BookingController {
|
||||
return this.bookingService.cancelBooking(userId, id)
|
||||
}
|
||||
|
||||
@Get('booking/my/activity')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async getPracticeActivity(@CurrentUser('sub') userId: string) {
|
||||
return this.bookingService.getPracticeActivity(userId)
|
||||
}
|
||||
|
||||
@Get('booking/my/upcoming')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async getUpcomingBookings(@CurrentUser('sub') userId: string) {
|
||||
@@ -91,6 +99,26 @@ export class BookingController {
|
||||
)
|
||||
}
|
||||
|
||||
@Post('admin/bookings')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
async arrangeBooking(
|
||||
@CurrentUser('sub') operatorId: string,
|
||||
@Body() dto: AdminArrangeBookingDto,
|
||||
) {
|
||||
return this.bookingService.adminArrangeBooking(operatorId, dto)
|
||||
}
|
||||
|
||||
@Get('admin/teaching-schedule')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
async getTeachingSchedule(@Query('date') date?: string) {
|
||||
if (!date) {
|
||||
throw new BadRequestException('date is required')
|
||||
}
|
||||
return this.bookingService.getTeachingScheduleByDate(date)
|
||||
}
|
||||
|
||||
@Put('booking/:id/confirm')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
|
||||
@@ -4,9 +4,10 @@ import { BookingService } from './booking.service'
|
||||
import { MembershipModule } from '../membership/membership.module'
|
||||
import { StudioModule } from '../studio/studio.module'
|
||||
import { UserModule } from '../user/user.module'
|
||||
import { InviteModule } from '../invite/invite.module'
|
||||
|
||||
@Module({
|
||||
imports: [MembershipModule, StudioModule, UserModule],
|
||||
imports: [MembershipModule, StudioModule, UserModule, InviteModule],
|
||||
controllers: [BookingController],
|
||||
providers: [BookingService],
|
||||
exports: [BookingService],
|
||||
|
||||
@@ -6,12 +6,21 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common'
|
||||
import { Booking, Membership, TimeSlot, BookingStatusHistory } from '@prisma/client'
|
||||
import { BookingStatus, CardTypeCategory, MembershipStatus, TimeSlotStatus } from '@mp-pilates/shared'
|
||||
import {
|
||||
BookingStatus,
|
||||
CardTypeCategory,
|
||||
MembershipStatus,
|
||||
TimeSlotStatus,
|
||||
type TeachingScheduleSlot,
|
||||
type PracticeActivity,
|
||||
} from '@mp-pilates/shared'
|
||||
import { PrismaService } from '../prisma/prisma.service'
|
||||
import { MembershipService } from '../membership/membership.service'
|
||||
import { StudioService } from '../studio/studio.service'
|
||||
import { SubscriptionMessageService } from '../user/subscription-message.service'
|
||||
import { CreateBookingDto } from './dto/create-booking.dto'
|
||||
import { AdminArrangeBookingDto } from './dto/admin-arrange-booking.dto'
|
||||
import { InviteService } from '../invite/invite.service'
|
||||
|
||||
// ─── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -50,6 +59,7 @@ export class BookingService {
|
||||
private readonly membershipService: MembershipService,
|
||||
private readonly studioService: StudioService,
|
||||
private readonly subscriptionMessageService: SubscriptionMessageService,
|
||||
private readonly inviteService: InviteService,
|
||||
) {}
|
||||
|
||||
// ─── Create Booking ──────────────────────────────────────────────────────
|
||||
@@ -106,18 +116,9 @@ export class BookingService {
|
||||
)
|
||||
}
|
||||
|
||||
const cardType = membership.cardType
|
||||
const isTimeBased =
|
||||
cardType.type === CardTypeCategory.TIMES ||
|
||||
cardType.type === CardTypeCategory.TRIAL
|
||||
|
||||
if (isTimeBased) {
|
||||
// 4a. TIMES / TRIAL: must have remaining times (check at confirm time, not booking time)
|
||||
} else {
|
||||
// 4b. DURATION: must not be expired
|
||||
if (membership.expireDate <= new Date()) {
|
||||
throw new BadRequestException('Membership has expired')
|
||||
}
|
||||
// A card cannot be used after its validity period ends.
|
||||
if (membership.expireDate <= new Date()) {
|
||||
throw new BadRequestException('Membership has expired')
|
||||
}
|
||||
|
||||
// 5. Create booking or revive a previously cancelled booking.
|
||||
@@ -126,6 +127,7 @@ export class BookingService {
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
membershipId: dto.membershipId,
|
||||
membershipTimesDeducted: false,
|
||||
status: BookingStatus.PENDING_CONFIRMATION,
|
||||
cancelledAt: null,
|
||||
confirmedAt: null,
|
||||
@@ -138,6 +140,7 @@ export class BookingService {
|
||||
userId,
|
||||
timeSlotId: dto.timeSlotId,
|
||||
membershipId: dto.membershipId,
|
||||
membershipTimesDeducted: false,
|
||||
status: BookingStatus.PENDING_CONFIRMATION,
|
||||
},
|
||||
})
|
||||
@@ -193,19 +196,16 @@ export class BookingService {
|
||||
}
|
||||
|
||||
// 2. Validate membership still has available times
|
||||
const cardType = existing.membership.cardType
|
||||
const isTimeBased =
|
||||
cardType.type === CardTypeCategory.TIMES ||
|
||||
cardType.type === CardTypeCategory.TRIAL
|
||||
if (existing.membership.expireDate <= new Date()) {
|
||||
throw new BadRequestException('Membership has expired')
|
||||
}
|
||||
|
||||
if (isTimeBased) {
|
||||
const isCountLimited = existing.membership.remainingTimes !== null
|
||||
|
||||
if (isCountLimited) {
|
||||
if ((existing.membership.remainingTimes ?? 0) <= 0) {
|
||||
throw new BadRequestException('No remaining times on this membership')
|
||||
}
|
||||
} else {
|
||||
if (existing.membership.expireDate <= new Date()) {
|
||||
throw new BadRequestException('Membership has expired')
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Update booking status to CONFIRMED
|
||||
@@ -213,6 +213,7 @@ export class BookingService {
|
||||
where: { id: bookingId },
|
||||
data: {
|
||||
status: BookingStatus.CONFIRMED,
|
||||
membershipTimesDeducted: isCountLimited,
|
||||
confirmedAt: new Date(),
|
||||
operatorId,
|
||||
},
|
||||
@@ -232,7 +233,7 @@ export class BookingService {
|
||||
})
|
||||
|
||||
// 5. Deduct membership times
|
||||
if (isTimeBased) {
|
||||
if (isCountLimited) {
|
||||
const newRemainingTimes = (existing.membership.remainingTimes ?? 0) - 1
|
||||
const newMembershipStatus =
|
||||
newRemainingTimes <= 0 ? MembershipStatus.USED_UP : MembershipStatus.ACTIVE
|
||||
@@ -265,6 +266,150 @@ export class BookingService {
|
||||
return confirmedBooking
|
||||
}
|
||||
|
||||
async adminArrangeBooking(
|
||||
operatorId: string,
|
||||
dto: AdminArrangeBookingDto,
|
||||
): Promise<BookingWithRelations> {
|
||||
const booking = await this.prisma.$transaction(async (tx) => {
|
||||
const timeSlot = await tx.timeSlot.findUnique({
|
||||
where: { id: dto.timeSlotId },
|
||||
})
|
||||
if (!timeSlot) {
|
||||
throw new NotFoundException(`TimeSlot ${dto.timeSlotId} not found`)
|
||||
}
|
||||
if (timeSlot.status !== TimeSlotStatus.OPEN) {
|
||||
throw new BadRequestException(
|
||||
`TimeSlot is not available (status: ${timeSlot.status})`,
|
||||
)
|
||||
}
|
||||
if (Date.now() >= buildSlotStartMs(timeSlot.date, timeSlot.startTime)) {
|
||||
throw new BadRequestException('Cannot arrange a past time slot')
|
||||
}
|
||||
|
||||
const user = await tx.user.findUnique({
|
||||
where: { id: dto.userId },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!user) {
|
||||
throw new NotFoundException(`User ${dto.userId} not found`)
|
||||
}
|
||||
|
||||
const existing = await tx.booking.findUnique({
|
||||
where: {
|
||||
userId_timeSlotId: {
|
||||
userId: dto.userId,
|
||||
timeSlotId: timeSlot.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
if (existing && existing.status !== BookingStatus.CANCELLED) {
|
||||
throw new ConflictException('Member already has a booking for this time slot')
|
||||
}
|
||||
|
||||
const membership = await tx.membership.findUnique({
|
||||
where: { id: dto.membershipId },
|
||||
include: { cardType: true },
|
||||
})
|
||||
if (!membership) {
|
||||
throw new NotFoundException(`Membership ${dto.membershipId} not found`)
|
||||
}
|
||||
if (membership.userId !== dto.userId) {
|
||||
throw new ForbiddenException('This membership does not belong to the member')
|
||||
}
|
||||
if (membership.status !== MembershipStatus.ACTIVE) {
|
||||
throw new BadRequestException(
|
||||
`Membership is not active (status: ${membership.status})`,
|
||||
)
|
||||
}
|
||||
if (membership.expireDate <= new Date()) {
|
||||
throw new BadRequestException('Membership has expired')
|
||||
}
|
||||
|
||||
const isCountLimited = membership.remainingTimes !== null
|
||||
|
||||
if (isCountLimited && (membership.remainingTimes ?? 0) <= 0) {
|
||||
throw new BadRequestException('No remaining times on this membership')
|
||||
}
|
||||
|
||||
const occupied = await tx.timeSlot.updateMany({
|
||||
where: {
|
||||
id: timeSlot.id,
|
||||
status: TimeSlotStatus.OPEN,
|
||||
bookedCount: { lt: timeSlot.capacity },
|
||||
},
|
||||
data: {
|
||||
bookedCount: { increment: 1 },
|
||||
},
|
||||
})
|
||||
if (occupied.count !== 1) {
|
||||
throw new BadRequestException('Time slot is full')
|
||||
}
|
||||
|
||||
const occupiedSlot = await tx.timeSlot.findUnique({ where: { id: timeSlot.id } })
|
||||
if (occupiedSlot && occupiedSlot.bookedCount >= occupiedSlot.capacity) {
|
||||
await tx.timeSlot.update({
|
||||
where: { id: timeSlot.id },
|
||||
data: { status: TimeSlotStatus.FULL },
|
||||
})
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const arranged = existing
|
||||
? await tx.booking.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
membershipId: dto.membershipId,
|
||||
membershipTimesDeducted: isCountLimited,
|
||||
status: BookingStatus.CONFIRMED,
|
||||
cancelledAt: null,
|
||||
confirmedAt: now,
|
||||
completedAt: null,
|
||||
operatorId,
|
||||
},
|
||||
})
|
||||
: await tx.booking.create({
|
||||
data: {
|
||||
userId: dto.userId,
|
||||
timeSlotId: timeSlot.id,
|
||||
membershipId: dto.membershipId,
|
||||
membershipTimesDeducted: isCountLimited,
|
||||
status: BookingStatus.CONFIRMED,
|
||||
confirmedAt: now,
|
||||
operatorId,
|
||||
},
|
||||
})
|
||||
|
||||
if (isCountLimited) {
|
||||
const newRemainingTimes = (membership.remainingTimes ?? 0) - 1
|
||||
await tx.membership.update({
|
||||
where: { id: membership.id },
|
||||
data: {
|
||||
remainingTimes: newRemainingTimes,
|
||||
status: newRemainingTimes <= 0 ? MembershipStatus.USED_UP : MembershipStatus.ACTIVE,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await tx.bookingStatusHistory.create({
|
||||
data: {
|
||||
bookingId: arranged.id,
|
||||
fromStatus: existing?.status === BookingStatus.CANCELLED
|
||||
? BookingStatus.CANCELLED
|
||||
: null,
|
||||
toStatus: BookingStatus.CONFIRMED,
|
||||
operatorId,
|
||||
remark: '老师代为安排',
|
||||
},
|
||||
})
|
||||
|
||||
return arranged
|
||||
})
|
||||
|
||||
const arrangedBooking = await this.fetchBookingWithRelations(booking.id)
|
||||
await this.trySendBookingConfirmedSubscriptionMessage(arrangedBooking)
|
||||
return arrangedBooking
|
||||
}
|
||||
|
||||
// ─── Complete / NoShow Booking (Admin) ──────────────────────────────────
|
||||
|
||||
async completeBooking(
|
||||
@@ -330,7 +475,11 @@ export class BookingService {
|
||||
return updated
|
||||
})
|
||||
|
||||
return this.fetchBookingWithRelations(booking.id)
|
||||
const result = await this.fetchBookingWithRelations(booking.id)
|
||||
if (toStatus === BookingStatus.COMPLETED) {
|
||||
await this.inviteService.recordQualifiedTrialBooking(result.id)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ─── Cancel Booking ──────────────────────────────────────────────────────
|
||||
@@ -416,13 +565,18 @@ export class BookingService {
|
||||
})
|
||||
|
||||
// Conditionally restore membership
|
||||
if (withinLimit) {
|
||||
const cardType = booking.membership.cardType
|
||||
const isTimeBased =
|
||||
cardType.type === CardTypeCategory.TIMES ||
|
||||
cardType.type === CardTypeCategory.TRIAL
|
||||
// Legacy bookings predate the snapshot. Keep their former category-based
|
||||
// refund behavior instead of inferring from a count that may be edited.
|
||||
const membershipTimesDeducted = booking.membershipTimesDeducted ?? (
|
||||
booking.membership.cardType.type === CardTypeCategory.TIMES ||
|
||||
booking.membership.cardType.type === CardTypeCategory.TRIAL
|
||||
)
|
||||
|
||||
if (isTimeBased) {
|
||||
if (withinLimit && membershipTimesDeducted) {
|
||||
// The snapshot reflects confirmation-time behavior. The card may have
|
||||
// been edited after confirmation, so its current count must not decide
|
||||
// whether this booking earns a refund.
|
||||
if (booking.membership.remainingTimes !== null) {
|
||||
const newRemainingTimes = (booking.membership.remainingTimes ?? 0) + 1
|
||||
const newStatus =
|
||||
booking.membership.status === MembershipStatus.USED_UP
|
||||
@@ -517,6 +671,34 @@ export class BookingService {
|
||||
|
||||
// ─── Get Upcoming Bookings ────────────────────────────────────────────────
|
||||
|
||||
async getPracticeActivity(userId: string): Promise<PracticeActivity> {
|
||||
// Slot dates are stored as UTC midnight date-only values. Determine today's
|
||||
// calendar date in China independently of the server's timezone.
|
||||
const dayMs = 86_400_000
|
||||
const today = new Date(Date.now() + 8 * 3_600_000).toISOString().slice(0, 10)
|
||||
const end = new Date(today + 'T00:00:00Z').getTime()
|
||||
const start = end - 29 * dayMs
|
||||
const bookings = await this.prisma.booking.findMany({
|
||||
where: {
|
||||
userId,
|
||||
status: BookingStatus.COMPLETED,
|
||||
timeSlot: { date: { gte: new Date(start), lt: new Date(end + dayMs) } },
|
||||
},
|
||||
select: { timeSlot: { select: { date: true } } },
|
||||
})
|
||||
const counts = new Map<string, number>()
|
||||
for (const booking of bookings) {
|
||||
const date = booking.timeSlot.date.toISOString().slice(0, 10)
|
||||
counts.set(date, (counts.get(date) ?? 0) + 1)
|
||||
}
|
||||
return {
|
||||
days: Array.from({ length: 30 }, (_, index) => {
|
||||
const date = new Date(start + index * dayMs).toISOString().slice(0, 10)
|
||||
return { date, count: counts.get(date) ?? 0 }
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async getUpcomingBookings(userId: string): Promise<BookingWithRelations[]> {
|
||||
const today = new Date()
|
||||
today.setUTCHours(0, 0, 0, 0)
|
||||
@@ -576,6 +758,72 @@ export class BookingService {
|
||||
}
|
||||
}
|
||||
|
||||
async getTeachingScheduleByDate(date: string): Promise<TeachingScheduleSlot[]> {
|
||||
const dayStart = new Date(`${date}T00:00:00.000Z`)
|
||||
if (Number.isNaN(dayStart.getTime())) {
|
||||
throw new BadRequestException('Invalid date')
|
||||
}
|
||||
|
||||
const slots = await this.prisma.timeSlot.findMany({
|
||||
where: {
|
||||
date: dayStart,
|
||||
bookings: {
|
||||
some: {
|
||||
status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] },
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
bookings: {
|
||||
where: {
|
||||
status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] },
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
nickname: true,
|
||||
phone: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{ status: 'asc' },
|
||||
{ createdAt: 'asc' },
|
||||
],
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{ startTime: 'asc' },
|
||||
{ endTime: 'asc' },
|
||||
],
|
||||
})
|
||||
|
||||
return slots
|
||||
.map((slot) => ({
|
||||
slotId: slot.id,
|
||||
date,
|
||||
startTime: slot.startTime,
|
||||
endTime: slot.endTime,
|
||||
bookedCount: slot.bookedCount,
|
||||
capacity: slot.capacity,
|
||||
students: slot.bookings.map((booking) => ({
|
||||
bookingId: booking.id,
|
||||
userId: booking.user.id,
|
||||
nickname: booking.user.nickname,
|
||||
phone: booking.user.phone,
|
||||
status: booking.status as BookingStatus,
|
||||
})),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const byStart = a.startTime.localeCompare(b.startTime)
|
||||
if (byStart !== 0) {
|
||||
return byStart
|
||||
}
|
||||
return a.endTime.localeCompare(b.endTime)
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Private Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
private async fetchBookingWithRelations(bookingId: string): Promise<BookingWithRelations> {
|
||||
|
||||
12
packages/server/src/booking/dto/admin-arrange-booking.dto.ts
Normal file
12
packages/server/src/booking/dto/admin-arrange-booking.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { IsUUID } from 'class-validator'
|
||||
|
||||
export class AdminArrangeBookingDto {
|
||||
@IsUUID()
|
||||
userId!: string
|
||||
|
||||
@IsUUID()
|
||||
membershipId!: string
|
||||
|
||||
@IsUUID()
|
||||
timeSlotId!: string
|
||||
}
|
||||
3
packages/server/src/invite/invite.constants.ts
Normal file
3
packages/server/src/invite/invite.constants.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const INVITE_REWARD_REQUIRED_COUNT = 3
|
||||
export const INVITE_REWARD_TIMES = 1
|
||||
|
||||
16
packages/server/src/invite/invite.controller.ts
Normal file
16
packages/server/src/invite/invite.controller.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common'
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator'
|
||||
import { InviteService } from './invite.service'
|
||||
|
||||
@Controller('invite')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class InviteController {
|
||||
constructor(private readonly inviteService: InviteService) {}
|
||||
|
||||
@Get('activity')
|
||||
getActivity(@CurrentUser('sub') userId: string) {
|
||||
return this.inviteService.getInviteActivitySummary(userId)
|
||||
}
|
||||
}
|
||||
|
||||
11
packages/server/src/invite/invite.module.ts
Normal file
11
packages/server/src/invite/invite.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { InviteController } from './invite.controller'
|
||||
import { InviteService } from './invite.service'
|
||||
|
||||
@Module({
|
||||
controllers: [InviteController],
|
||||
providers: [InviteService],
|
||||
exports: [InviteService],
|
||||
})
|
||||
export class InviteModule {}
|
||||
|
||||
253
packages/server/src/invite/invite.service.ts
Normal file
253
packages/server/src/invite/invite.service.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common'
|
||||
import type { InviteReferral, InviteRewardGrant, Membership } from '@prisma/client'
|
||||
import { InviteReferralStatus, MembershipStatus, OrderStatus } from '@mp-pilates/shared'
|
||||
import type { InviteActivitySummary } from '@mp-pilates/shared'
|
||||
import { PrismaService } from '../prisma/prisma.service'
|
||||
import {
|
||||
INVITE_REWARD_REQUIRED_COUNT,
|
||||
INVITE_REWARD_TIMES,
|
||||
} from './invite.constants'
|
||||
|
||||
@Injectable()
|
||||
export class InviteService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
private isTrialCardType(type: string): boolean {
|
||||
return type === 'TRIAL'
|
||||
}
|
||||
|
||||
async bindInviterToUser(inviteeId: string, inviterId?: string | null): Promise<void> {
|
||||
if (!inviterId || inviterId === inviteeId) {
|
||||
return
|
||||
}
|
||||
|
||||
const [inviter, inviteeMembershipCount, existingReferral] = await Promise.all([
|
||||
this.prisma.user.findUnique({ where: { id: inviterId }, select: { id: true } }),
|
||||
this.prisma.membership.count({ where: { userId: inviteeId } }),
|
||||
this.prisma.inviteReferral.findUnique({ where: { inviteeId } }),
|
||||
])
|
||||
|
||||
if (!inviter || inviteeMembershipCount > 0 || existingReferral) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.prisma.inviteReferral.create({
|
||||
data: {
|
||||
inviterId,
|
||||
inviteeId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async recordTrialOrderPaid(orderId: string): Promise<void> {
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
include: {
|
||||
cardType: true,
|
||||
user: { select: { id: true } },
|
||||
},
|
||||
})
|
||||
|
||||
if (!order || order.status !== OrderStatus.PAID || !this.isTrialCardType(order.cardType.type)) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.prisma.inviteReferral.updateMany({
|
||||
where: {
|
||||
inviteeId: order.user.id,
|
||||
status: InviteReferralStatus.REGISTERED,
|
||||
trialOrderId: null,
|
||||
},
|
||||
data: {
|
||||
status: InviteReferralStatus.TRIAL_PURCHASED,
|
||||
trialOrderId: order.id,
|
||||
trialPurchasedAt: order.paidAt ?? new Date(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async recordQualifiedTrialBooking(bookingId: string): Promise<void> {
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
include: {
|
||||
membership: { include: { cardType: true } },
|
||||
},
|
||||
})
|
||||
|
||||
if (!booking || booking.status !== 'COMPLETED' || !this.isTrialCardType(booking.membership.cardType.type)) {
|
||||
return
|
||||
}
|
||||
|
||||
const referral = await this.prisma.inviteReferral.findFirst({
|
||||
where: {
|
||||
inviteeId: booking.userId,
|
||||
status: {
|
||||
in: [InviteReferralStatus.REGISTERED, InviteReferralStatus.TRIAL_PURCHASED],
|
||||
},
|
||||
qualifiedBookingId: null,
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
if (!referral) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.prisma.inviteReferral.update({
|
||||
where: { id: referral.id },
|
||||
data: {
|
||||
status: InviteReferralStatus.QUALIFIED,
|
||||
qualifiedBookingId: booking.id,
|
||||
qualifiedAt: booking.completedAt ?? new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
await this.grantRewardsIfEligible(referral.inviterId)
|
||||
}
|
||||
|
||||
async getInviteActivitySummary(userId: string): Promise<InviteActivitySummary> {
|
||||
const memberships = await this.prisma.membership.findMany({
|
||||
where: { userId },
|
||||
orderBy: [{ status: 'asc' }, { expireDate: 'desc' }],
|
||||
})
|
||||
const referrals = await this.prisma.inviteReferral.findMany({
|
||||
where: { inviterId: userId },
|
||||
include: {
|
||||
invitee: {
|
||||
select: {
|
||||
id: true,
|
||||
nickname: true,
|
||||
avatarUrl: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
const rewardGrants = await this.prisma.inviteRewardGrant.findMany({
|
||||
where: { inviterId: userId },
|
||||
orderBy: { grantedAt: 'desc' },
|
||||
})
|
||||
|
||||
const canInvite = memberships.some((membership: Membership) => membership.status === MembershipStatus.ACTIVE)
|
||||
const qualifiedInviteCount = referrals.filter((item: InviteReferral) => item.status === InviteReferralStatus.QUALIFIED).length
|
||||
const rewardedTimes = rewardGrants.reduce((sum: number, item: InviteRewardGrant) => sum + item.rewardTimes, 0)
|
||||
const pendingRewardGrantCount = Math.max(
|
||||
0,
|
||||
qualifiedInviteCount - rewardGrants.length * INVITE_REWARD_REQUIRED_COUNT,
|
||||
)
|
||||
const currentCycleQualifiedCount = qualifiedInviteCount % INVITE_REWARD_REQUIRED_COUNT
|
||||
|
||||
return {
|
||||
inviterId: userId,
|
||||
canInvite,
|
||||
sharePath: `/pages/profile/invite?inviterId=${userId}`,
|
||||
rewardRuleInvitesRequired: INVITE_REWARD_REQUIRED_COUNT,
|
||||
rewardRuleTimes: INVITE_REWARD_TIMES,
|
||||
qualifiedInviteCount,
|
||||
rewardedTimes,
|
||||
pendingRewardGrantCount,
|
||||
pendingInviteCount: referrals.filter((item: InviteReferral) => item.status !== InviteReferralStatus.QUALIFIED).length,
|
||||
currentCycleQualifiedCount,
|
||||
nextRewardRemainingCount: currentCycleQualifiedCount === 0
|
||||
? INVITE_REWARD_REQUIRED_COUNT
|
||||
: INVITE_REWARD_REQUIRED_COUNT - currentCycleQualifiedCount,
|
||||
referrals: referrals.map((item: InviteReferral & { invitee: { nickname: string; avatarUrl: string | null } }) => ({
|
||||
id: item.id,
|
||||
inviteeId: item.inviteeId,
|
||||
inviteeNickname: item.invitee.nickname,
|
||||
inviteeAvatarUrl: item.invitee.avatarUrl,
|
||||
status: item.status as InviteReferralStatus,
|
||||
invitedAt: item.invitedAt.toISOString(),
|
||||
trialPurchasedAt: item.trialPurchasedAt?.toISOString() ?? null,
|
||||
qualifiedAt: item.qualifiedAt?.toISOString() ?? null,
|
||||
})),
|
||||
rewardGrants: rewardGrants.map((item: InviteRewardGrant) => ({
|
||||
id: item.id,
|
||||
membershipId: item.membershipId,
|
||||
qualifiedReferralCount: item.qualifiedReferralCount,
|
||||
rewardTimes: item.rewardTimes,
|
||||
grantedAt: item.grantedAt.toISOString(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async validateInviterForTrialOrder(userId: string, inviterId?: string): Promise<void> {
|
||||
if (!inviterId) {
|
||||
return
|
||||
}
|
||||
|
||||
if (inviterId === userId) {
|
||||
throw new BadRequestException('不能邀请自己购买体验课')
|
||||
}
|
||||
|
||||
const referral = await this.prisma.inviteReferral.findFirst({
|
||||
where: {
|
||||
inviterId,
|
||||
inviteeId: userId,
|
||||
},
|
||||
})
|
||||
|
||||
if (!referral) {
|
||||
throw new NotFoundException('邀请关系不存在或已失效')
|
||||
}
|
||||
}
|
||||
|
||||
private async grantRewardsIfEligible(inviterId: string): Promise<void> {
|
||||
const [qualifiedCount, rewardGrantCount] = await Promise.all([
|
||||
this.prisma.inviteReferral.count({
|
||||
where: {
|
||||
inviterId,
|
||||
status: InviteReferralStatus.QUALIFIED,
|
||||
},
|
||||
}),
|
||||
this.prisma.inviteRewardGrant.count({ where: { inviterId } }),
|
||||
])
|
||||
|
||||
const shouldGrantCount = Math.floor(qualifiedCount / INVITE_REWARD_REQUIRED_COUNT)
|
||||
const missingGrantCount = shouldGrantCount - rewardGrantCount
|
||||
|
||||
if (missingGrantCount <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
for (let index = 0; index < missingGrantCount; index += 1) {
|
||||
const targetQualifiedCount = (rewardGrantCount + index + 1) * INVITE_REWARD_REQUIRED_COUNT
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const membership = await tx.membership.findFirst({
|
||||
where: {
|
||||
userId: inviterId,
|
||||
status: MembershipStatus.ACTIVE,
|
||||
},
|
||||
orderBy: [{ expireDate: 'desc' }, { createdAt: 'desc' }],
|
||||
})
|
||||
|
||||
if (!membership) {
|
||||
throw new BadRequestException('邀请人当前没有有效会员卡,无法发放奖励')
|
||||
}
|
||||
|
||||
await tx.membership.update({
|
||||
where: { id: membership.id },
|
||||
data: {
|
||||
remainingTimes: membership.remainingTimes === null
|
||||
? null
|
||||
: membership.remainingTimes + INVITE_REWARD_TIMES,
|
||||
status: MembershipStatus.ACTIVE,
|
||||
},
|
||||
})
|
||||
|
||||
await tx.inviteRewardGrant.create({
|
||||
data: {
|
||||
inviterId,
|
||||
membershipId: membership.id,
|
||||
qualifiedReferralCount: targetQualifiedCount,
|
||||
rewardTimes: INVITE_REWARD_TIMES,
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import {
|
||||
CardTypeCategory,
|
||||
MembershipStatus,
|
||||
RENEWAL_DAYS_THRESHOLD,
|
||||
RENEWAL_TIMES_THRESHOLD,
|
||||
computeMembershipGrant,
|
||||
getMembershipRenewalHint,
|
||||
pickRenewalTarget,
|
||||
} from '@mp-pilates/shared'
|
||||
import type { RenewalHintMembership } from '@mp-pilates/shared'
|
||||
|
||||
const now = new Date('2026-06-01T00:00:00Z')
|
||||
|
||||
const timesCard = {
|
||||
type: CardTypeCategory.TIMES,
|
||||
totalTimes: 10,
|
||||
durationDays: 90,
|
||||
}
|
||||
|
||||
const durationCard = {
|
||||
type: CardTypeCategory.DURATION,
|
||||
totalTimes: null,
|
||||
durationDays: 30,
|
||||
}
|
||||
|
||||
const limitedDurationCard = {
|
||||
type: CardTypeCategory.DURATION,
|
||||
totalTimes: 10,
|
||||
durationDays: 30,
|
||||
}
|
||||
|
||||
const trialCard = {
|
||||
type: CardTypeCategory.TRIAL,
|
||||
totalTimes: 1,
|
||||
durationDays: 7,
|
||||
}
|
||||
|
||||
function membership(overrides: Partial<RenewalHintMembership> & { cardTypeId: string }): RenewalHintMembership {
|
||||
return {
|
||||
remainingTimes: null,
|
||||
expireDate: '2026-12-01T00:00:00.000Z',
|
||||
status: MembershipStatus.ACTIVE,
|
||||
cardType: { type: CardTypeCategory.TIMES },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('computeMembershipGrant', () => {
|
||||
it('creates a fresh grant when there is no existing membership', () => {
|
||||
const result = computeMembershipGrant({ existing: null, cardType: timesCard, now })
|
||||
|
||||
expect(result.isRenewal).toBe(false)
|
||||
expect(result.remainingTimes).toBe(10)
|
||||
expect(result.totalTimes).toBe(10)
|
||||
expect(result.expireDate.getTime()).toBe(now.getTime() + 90 * 86_400_000)
|
||||
})
|
||||
|
||||
it('always treats TRIAL as a new grant even if an old trial exists', () => {
|
||||
const result = computeMembershipGrant({
|
||||
existing: {
|
||||
remainingTimes: 0,
|
||||
totalTimes: 1,
|
||||
expireDate: '2026-08-01T00:00:00.000Z',
|
||||
status: MembershipStatus.ACTIVE,
|
||||
},
|
||||
cardType: trialCard,
|
||||
now,
|
||||
})
|
||||
|
||||
expect(result.isRenewal).toBe(false)
|
||||
expect(result.remainingTimes).toBe(1)
|
||||
expect(result.totalTimes).toBe(1)
|
||||
})
|
||||
|
||||
it('stacks times and extends expireDate for an active TIMES card', () => {
|
||||
const expireDate = new Date('2026-09-01T00:00:00Z')
|
||||
const result = computeMembershipGrant({
|
||||
existing: {
|
||||
remainingTimes: 3,
|
||||
totalTimes: 10,
|
||||
expireDate,
|
||||
status: MembershipStatus.ACTIVE,
|
||||
},
|
||||
cardType: timesCard,
|
||||
now,
|
||||
})
|
||||
|
||||
expect(result.isRenewal).toBe(true)
|
||||
expect(result.remainingTimes).toBe(13)
|
||||
expect(result.totalTimes).toBe(20)
|
||||
expect(result.expireDate.getTime()).toBe(expireDate.getTime() + 90 * 86_400_000)
|
||||
})
|
||||
|
||||
it('does not inherit leftover times when the TIMES card is expired', () => {
|
||||
const result = computeMembershipGrant({
|
||||
existing: {
|
||||
remainingTimes: 4,
|
||||
totalTimes: 10,
|
||||
expireDate: '2026-01-01T00:00:00.000Z',
|
||||
status: MembershipStatus.EXPIRED,
|
||||
},
|
||||
cardType: timesCard,
|
||||
now,
|
||||
})
|
||||
|
||||
expect(result.isRenewal).toBe(true)
|
||||
expect(result.remainingTimes).toBe(10)
|
||||
expect(result.totalTimes).toBe(10)
|
||||
expect(result.expireDate.getTime()).toBe(now.getTime() + 90 * 86_400_000)
|
||||
})
|
||||
|
||||
it('extends a still-active DURATION card from its current expireDate', () => {
|
||||
const expireDate = new Date('2026-07-01T00:00:00Z')
|
||||
const result = computeMembershipGrant({
|
||||
existing: {
|
||||
remainingTimes: null,
|
||||
totalTimes: null,
|
||||
expireDate,
|
||||
status: MembershipStatus.ACTIVE,
|
||||
},
|
||||
cardType: durationCard,
|
||||
now,
|
||||
})
|
||||
|
||||
expect(result.isRenewal).toBe(true)
|
||||
expect(result.remainingTimes).toBeNull()
|
||||
expect(result.expireDate.getTime()).toBe(expireDate.getTime() + 30 * 86_400_000)
|
||||
})
|
||||
|
||||
it('starts an expired DURATION card from now', () => {
|
||||
const result = computeMembershipGrant({
|
||||
existing: {
|
||||
remainingTimes: null,
|
||||
totalTimes: null,
|
||||
expireDate: '2026-01-01T00:00:00.000Z',
|
||||
status: MembershipStatus.EXPIRED,
|
||||
},
|
||||
cardType: durationCard,
|
||||
now,
|
||||
})
|
||||
|
||||
expect(result.isRenewal).toBe(true)
|
||||
expect(result.expireDate.getTime()).toBe(now.getTime() + 30 * 86_400_000)
|
||||
})
|
||||
|
||||
it('stacks sessions for an active count-limited DURATION card', () => {
|
||||
const expireDate = new Date('2026-07-01T00:00:00Z')
|
||||
const result = computeMembershipGrant({
|
||||
existing: {
|
||||
remainingTimes: 3,
|
||||
totalTimes: 10,
|
||||
expireDate,
|
||||
status: MembershipStatus.ACTIVE,
|
||||
},
|
||||
cardType: limitedDurationCard,
|
||||
now,
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
isRenewal: true,
|
||||
remainingTimes: 13,
|
||||
totalTimes: 20,
|
||||
})
|
||||
expect(result.expireDate.getTime()).toBe(expireDate.getTime() + 30 * 86_400_000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMembershipRenewalHint', () => {
|
||||
it('prefers a TIMES card running low over a DURATION card near expiry', () => {
|
||||
const hint = getMembershipRenewalHint(
|
||||
[
|
||||
membership({
|
||||
cardTypeId: 'duration-1',
|
||||
remainingTimes: null,
|
||||
expireDate: new Date(now.getTime() + 3 * 86_400_000).toISOString(),
|
||||
cardType: { type: CardTypeCategory.DURATION },
|
||||
}),
|
||||
membership({
|
||||
cardTypeId: 'times-1',
|
||||
remainingTimes: RENEWAL_TIMES_THRESHOLD,
|
||||
expireDate: new Date(now.getTime() + 60 * 86_400_000).toISOString(),
|
||||
cardType: { type: CardTypeCategory.TIMES },
|
||||
}),
|
||||
],
|
||||
now,
|
||||
)
|
||||
|
||||
expect(hint).toMatchObject({
|
||||
kind: 'times_low',
|
||||
cardTypeId: 'times-1',
|
||||
remainingTimes: RENEWAL_TIMES_THRESHOLD,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns days_low when a DURATION card is within the threshold', () => {
|
||||
const hint = getMembershipRenewalHint(
|
||||
[
|
||||
membership({
|
||||
cardTypeId: 'duration-1',
|
||||
remainingTimes: null,
|
||||
expireDate: new Date(now.getTime() + RENEWAL_DAYS_THRESHOLD * 86_400_000).toISOString(),
|
||||
cardType: { type: CardTypeCategory.DURATION },
|
||||
}),
|
||||
],
|
||||
now,
|
||||
)
|
||||
|
||||
expect(hint).toMatchObject({
|
||||
kind: 'days_low',
|
||||
cardTypeId: 'duration-1',
|
||||
daysLeft: RENEWAL_DAYS_THRESHOLD,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns times_low for a count-limited DURATION card', () => {
|
||||
const hint = getMembershipRenewalHint(
|
||||
[
|
||||
membership({
|
||||
cardTypeId: 'limited-duration-1',
|
||||
remainingTimes: RENEWAL_TIMES_THRESHOLD,
|
||||
expireDate: new Date(now.getTime() + 20 * 86_400_000).toISOString(),
|
||||
cardType: { type: CardTypeCategory.DURATION },
|
||||
}),
|
||||
],
|
||||
now,
|
||||
)
|
||||
|
||||
expect(hint).toMatchObject({
|
||||
kind: 'times_low',
|
||||
cardTypeId: 'limited-duration-1',
|
||||
remainingTimes: RENEWAL_TIMES_THRESHOLD,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not recommend renewing a TRIAL card when times run low', () => {
|
||||
const hint = getMembershipRenewalHint(
|
||||
[
|
||||
membership({
|
||||
cardTypeId: 'trial-1',
|
||||
remainingTimes: 1,
|
||||
expireDate: new Date(now.getTime() + 5 * 86_400_000).toISOString(),
|
||||
cardType: { type: CardTypeCategory.TRIAL },
|
||||
}),
|
||||
],
|
||||
now,
|
||||
)
|
||||
|
||||
expect(hint).toMatchObject({
|
||||
kind: 'trial_low',
|
||||
cardTypeId: null,
|
||||
remainingTimes: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('recommends the most recently expired non-trial card', () => {
|
||||
const hint = getMembershipRenewalHint(
|
||||
[
|
||||
membership({
|
||||
cardTypeId: 'times-old',
|
||||
remainingTimes: 0,
|
||||
expireDate: '2026-01-01T00:00:00.000Z',
|
||||
status: MembershipStatus.EXPIRED,
|
||||
cardType: { type: CardTypeCategory.TIMES },
|
||||
}),
|
||||
membership({
|
||||
cardTypeId: 'duration-latest',
|
||||
remainingTimes: null,
|
||||
expireDate: '2026-05-01T00:00:00.000Z',
|
||||
status: MembershipStatus.EXPIRED,
|
||||
cardType: { type: CardTypeCategory.DURATION },
|
||||
}),
|
||||
membership({
|
||||
cardTypeId: 'trial-1',
|
||||
remainingTimes: 0,
|
||||
expireDate: '2026-05-15T00:00:00.000Z',
|
||||
status: MembershipStatus.USED_UP,
|
||||
cardType: { type: CardTypeCategory.TRIAL },
|
||||
}),
|
||||
],
|
||||
now,
|
||||
)
|
||||
|
||||
expect(hint).toMatchObject({
|
||||
kind: 'expired',
|
||||
cardTypeId: 'duration-latest',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null when active cards are healthy', () => {
|
||||
const hint = getMembershipRenewalHint(
|
||||
[
|
||||
membership({
|
||||
cardTypeId: 'times-1',
|
||||
remainingTimes: 8,
|
||||
expireDate: new Date(now.getTime() + 60 * 86_400_000).toISOString(),
|
||||
}),
|
||||
],
|
||||
now,
|
||||
)
|
||||
|
||||
expect(hint).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pickRenewalTarget', () => {
|
||||
it('picks the latest expiring membership of the same card type', () => {
|
||||
const target = pickRenewalTarget(
|
||||
[
|
||||
membership({
|
||||
cardTypeId: 'times-1',
|
||||
expireDate: '2026-01-01T00:00:00.000Z',
|
||||
}),
|
||||
membership({
|
||||
cardTypeId: 'times-1',
|
||||
expireDate: '2026-08-01T00:00:00.000Z',
|
||||
}),
|
||||
membership({
|
||||
cardTypeId: 'duration-1',
|
||||
expireDate: '2026-12-01T00:00:00.000Z',
|
||||
cardType: { type: CardTypeCategory.DURATION },
|
||||
}),
|
||||
],
|
||||
'times-1',
|
||||
)
|
||||
|
||||
expect(target?.expireDate).toBe('2026-08-01T00:00:00.000Z')
|
||||
})
|
||||
|
||||
it('does not pick a TRIAL membership as a renewal target', () => {
|
||||
const target = pickRenewalTarget(
|
||||
[
|
||||
membership({
|
||||
cardTypeId: 'trial-1',
|
||||
cardType: { type: CardTypeCategory.TRIAL },
|
||||
}),
|
||||
],
|
||||
'trial-1',
|
||||
)
|
||||
|
||||
expect(target).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -16,6 +16,7 @@ const mockTimesCardType = {
|
||||
price: 150000,
|
||||
originalPrice: null,
|
||||
description: null,
|
||||
coverUrl: null,
|
||||
isActive: true,
|
||||
sortOrder: 0,
|
||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||
@@ -31,6 +32,7 @@ const mockDurationCardType = {
|
||||
price: 80000,
|
||||
originalPrice: null,
|
||||
description: null,
|
||||
coverUrl: null,
|
||||
isActive: true,
|
||||
sortOrder: 1,
|
||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||
@@ -48,6 +50,7 @@ const mockActiveMembership = {
|
||||
userId: 'user-001',
|
||||
cardTypeId: mockTimesCardType.id,
|
||||
remainingTimes: 5,
|
||||
totalTimes: 10,
|
||||
startDate: new Date('2024-01-01T00:00:00Z'),
|
||||
expireDate: new Date('2099-12-31T00:00:00Z'),
|
||||
status: MembershipStatus.ACTIVE,
|
||||
@@ -61,6 +64,7 @@ const mockDurationMembership = {
|
||||
id: 'mem-duration-001',
|
||||
cardTypeId: mockDurationCardType.id,
|
||||
remainingTimes: null,
|
||||
totalTimes: null,
|
||||
cardType: mockDurationCardType,
|
||||
}
|
||||
|
||||
@@ -77,6 +81,7 @@ const mockPrismaService = {
|
||||
findMany: jest.fn(),
|
||||
findFirst: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
}
|
||||
@@ -219,7 +224,7 @@ describe('MembershipService', () => {
|
||||
expect(result.remainingTimes).toBe(0)
|
||||
})
|
||||
|
||||
it('should not change times for a DURATION card', async () => {
|
||||
it('should leave an unlimited DURATION card unchanged', async () => {
|
||||
mockPrismaService.membership.findUnique.mockResolvedValue(mockDurationMembership)
|
||||
|
||||
const result = await service.deductMembership('mem-duration-001')
|
||||
@@ -229,6 +234,27 @@ describe('MembershipService', () => {
|
||||
expect(result.status).toBe(MembershipStatus.ACTIVE)
|
||||
})
|
||||
|
||||
it('should decrement remainingTimes for a count-limited DURATION card', async () => {
|
||||
const membership = {
|
||||
...mockDurationMembership,
|
||||
remainingTimes: 5,
|
||||
totalTimes: 10,
|
||||
}
|
||||
mockPrismaService.membership.findUnique.mockResolvedValue(membership)
|
||||
mockPrismaService.membership.update.mockResolvedValue({
|
||||
...membership,
|
||||
remainingTimes: 4,
|
||||
})
|
||||
|
||||
await service.deductMembership('mem-duration-001')
|
||||
|
||||
expect(mockPrismaService.membership.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: { remainingTimes: 4, status: MembershipStatus.ACTIVE },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw NotFoundException when membership does not exist', async () => {
|
||||
mockPrismaService.membership.findUnique.mockResolvedValue(null)
|
||||
|
||||
@@ -293,6 +319,39 @@ describe('MembershipService', () => {
|
||||
expect(result.remainingTimes).toBe(4)
|
||||
})
|
||||
|
||||
it('should leave an unlimited DURATION card unchanged', async () => {
|
||||
mockPrismaService.membership.findUnique.mockResolvedValue(mockDurationMembership)
|
||||
|
||||
const result = await service.restoreMembership('mem-duration-001')
|
||||
|
||||
expect(mockPrismaService.membership.update).not.toHaveBeenCalled()
|
||||
expect(result.remainingTimes).toBeNull()
|
||||
expect(result.status).toBe(MembershipStatus.ACTIVE)
|
||||
})
|
||||
|
||||
it('should restore a count-limited DURATION card from USED_UP', async () => {
|
||||
const membership = {
|
||||
...mockDurationMembership,
|
||||
remainingTimes: 0,
|
||||
totalTimes: 10,
|
||||
status: MembershipStatus.USED_UP,
|
||||
}
|
||||
mockPrismaService.membership.findUnique.mockResolvedValue(membership)
|
||||
mockPrismaService.membership.update.mockResolvedValue({
|
||||
...membership,
|
||||
remainingTimes: 1,
|
||||
status: MembershipStatus.ACTIVE,
|
||||
})
|
||||
|
||||
await service.restoreMembership('mem-duration-001')
|
||||
|
||||
expect(mockPrismaService.membership.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: { remainingTimes: 1, status: MembershipStatus.ACTIVE },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw NotFoundException when membership does not exist', async () => {
|
||||
mockPrismaService.membership.findUnique.mockResolvedValue(null)
|
||||
|
||||
@@ -300,6 +359,160 @@ describe('MembershipService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ─── grantPurchasedCard ────────────────────────────────────────────────
|
||||
|
||||
describe('grantPurchasedCard()', () => {
|
||||
const now = new Date('2026-06-01T00:00:00Z')
|
||||
const tx = mockPrismaService as unknown as Parameters<MembershipService['grantPurchasedCard']>[0]
|
||||
|
||||
it('creates a new membership when the user has no card of that type', async () => {
|
||||
mockPrismaService.membership.findFirst.mockResolvedValue(null)
|
||||
const created = { ...mockActiveMembership, remainingTimes: 10, totalTimes: 10 }
|
||||
mockPrismaService.membership.create.mockResolvedValue(created)
|
||||
|
||||
const result = await service.grantPurchasedCard(tx, 'user-001', mockTimesCardType, now)
|
||||
|
||||
expect(mockPrismaService.membership.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
userId: 'user-001',
|
||||
cardTypeId: mockTimesCardType.id,
|
||||
remainingTimes: 10,
|
||||
totalTimes: 10,
|
||||
status: MembershipStatus.ACTIVE,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(mockPrismaService.membership.update).not.toHaveBeenCalled()
|
||||
expect(result.remainingTimes).toBe(10)
|
||||
})
|
||||
|
||||
it('stacks remaining times and extends expireDate for an active TIMES card', async () => {
|
||||
const existing = {
|
||||
...mockActiveMembership,
|
||||
remainingTimes: 3,
|
||||
totalTimes: 10,
|
||||
expireDate: new Date('2026-09-01T00:00:00Z'),
|
||||
}
|
||||
mockPrismaService.membership.findFirst.mockResolvedValue(existing)
|
||||
mockPrismaService.membership.update.mockResolvedValue({
|
||||
...existing,
|
||||
remainingTimes: 13,
|
||||
totalTimes: 20,
|
||||
})
|
||||
|
||||
await service.grantPurchasedCard(tx, 'user-001', mockTimesCardType, now)
|
||||
|
||||
expect(mockPrismaService.membership.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: existing.id },
|
||||
data: expect.objectContaining({
|
||||
remainingTimes: 13,
|
||||
totalTimes: 20,
|
||||
status: MembershipStatus.ACTIVE,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const updateData = mockPrismaService.membership.update.mock.calls[0][0].data as {
|
||||
expireDate: Date
|
||||
}
|
||||
expect(updateData.expireDate.getTime()).toBe(
|
||||
existing.expireDate.getTime() + mockTimesCardType.durationDays * 86_400_000,
|
||||
)
|
||||
expect(mockPrismaService.membership.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('extends a DURATION card from the current expireDate when still active', async () => {
|
||||
const existing = {
|
||||
...mockDurationMembership,
|
||||
expireDate: new Date('2026-07-01T00:00:00Z'),
|
||||
}
|
||||
mockPrismaService.membership.findFirst.mockResolvedValue(existing)
|
||||
mockPrismaService.membership.update.mockResolvedValue(existing)
|
||||
|
||||
await service.grantPurchasedCard(tx, 'user-001', mockDurationCardType, now)
|
||||
|
||||
expect(mockPrismaService.membership.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
remainingTimes: null,
|
||||
totalTimes: null,
|
||||
status: MembershipStatus.ACTIVE,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const updateData = mockPrismaService.membership.update.mock.calls[0][0].data as {
|
||||
expireDate: Date
|
||||
}
|
||||
expect(updateData.expireDate.getTime()).toBe(
|
||||
existing.expireDate.getTime() + mockDurationCardType.durationDays * 86_400_000,
|
||||
)
|
||||
})
|
||||
|
||||
it('does not carry leftover times when renewing an expired TIMES card', async () => {
|
||||
const existing = {
|
||||
...mockActiveMembership,
|
||||
remainingTimes: 4,
|
||||
totalTimes: 10,
|
||||
status: MembershipStatus.EXPIRED,
|
||||
expireDate: new Date('2026-01-01T00:00:00Z'),
|
||||
}
|
||||
mockPrismaService.membership.findFirst.mockResolvedValue(existing)
|
||||
mockPrismaService.membership.update.mockResolvedValue({
|
||||
...existing,
|
||||
remainingTimes: 10,
|
||||
status: MembershipStatus.ACTIVE,
|
||||
})
|
||||
|
||||
await service.grantPurchasedCard(tx, 'user-001', mockTimesCardType, now)
|
||||
|
||||
expect(mockPrismaService.membership.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
remainingTimes: 10,
|
||||
totalTimes: 10,
|
||||
status: MembershipStatus.ACTIVE,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const updateData = mockPrismaService.membership.update.mock.calls[0][0].data as {
|
||||
expireDate: Date
|
||||
}
|
||||
expect(updateData.expireDate.getTime()).toBe(
|
||||
now.getTime() + mockTimesCardType.durationDays * 86_400_000,
|
||||
)
|
||||
})
|
||||
|
||||
it('always creates a new membership for TRIAL cards', async () => {
|
||||
const trialCardType = {
|
||||
...mockTimesCardType,
|
||||
id: 'ct-trial-001',
|
||||
type: CardTypeCategory.TRIAL,
|
||||
totalTimes: 1,
|
||||
durationDays: 7,
|
||||
}
|
||||
mockPrismaService.membership.create.mockResolvedValue({
|
||||
...mockActiveMembership,
|
||||
cardTypeId: trialCardType.id,
|
||||
remainingTimes: 1,
|
||||
totalTimes: 1,
|
||||
})
|
||||
|
||||
await service.grantPurchasedCard(tx, 'user-001', trialCardType, now)
|
||||
|
||||
expect(mockPrismaService.membership.findFirst).not.toHaveBeenCalled()
|
||||
expect(mockPrismaService.membership.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
cardTypeId: trialCardType.id,
|
||||
remainingTimes: 1,
|
||||
totalTimes: 1,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── createCardType ────────────────────────────────────────────────────
|
||||
|
||||
describe('createCardType()', () => {
|
||||
|
||||
@@ -37,6 +37,10 @@ export class CreateCardTypeDto {
|
||||
@IsString()
|
||||
description?: string
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
coverUrl?: string
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
|
||||
@@ -42,6 +42,10 @@ export class UpdateCardTypeDto {
|
||||
@IsString()
|
||||
description?: string
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
coverUrl?: string
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'
|
||||
import { CardType, Membership } from '@prisma/client'
|
||||
import { CardTypeCategory, MembershipStatus } from '@mp-pilates/shared'
|
||||
import { CardType, Membership, Prisma } from '@prisma/client'
|
||||
import { CardTypeCategory, MembershipStatus, computeMembershipGrant } from '@mp-pilates/shared'
|
||||
import { PrismaService } from '../prisma/prisma.service'
|
||||
import { CreateCardTypeDto } from './dto/create-card-type.dto'
|
||||
import { UpdateCardTypeDto } from './dto/update-card-type.dto'
|
||||
@@ -59,12 +59,8 @@ export class MembershipService {
|
||||
throw new BadRequestException(`Membership ${membershipId} is not active`)
|
||||
}
|
||||
|
||||
const isTimeBased =
|
||||
membership.cardType.type === CardTypeCategory.TIMES ||
|
||||
membership.cardType.type === CardTypeCategory.TRIAL
|
||||
|
||||
if (!isTimeBased) {
|
||||
// DURATION card: validate expiry only, no times to deduct
|
||||
if (membership.remainingTimes === null) {
|
||||
// Unlimited memberships do not consume a session.
|
||||
return { ...membership, cardType: { ...membership.cardType } }
|
||||
}
|
||||
|
||||
@@ -93,11 +89,7 @@ export class MembershipService {
|
||||
throw new NotFoundException(`Membership ${membershipId} not found`)
|
||||
}
|
||||
|
||||
const isTimeBased =
|
||||
membership.cardType.type === CardTypeCategory.TIMES ||
|
||||
membership.cardType.type === CardTypeCategory.TRIAL
|
||||
|
||||
if (!isTimeBased) {
|
||||
if (membership.remainingTimes === null) {
|
||||
return { ...membership, cardType: { ...membership.cardType } }
|
||||
}
|
||||
|
||||
@@ -119,6 +111,55 @@ export class MembershipService {
|
||||
return { ...updated, cardType: { ...updated.cardType } }
|
||||
}
|
||||
|
||||
async grantPurchasedCard(
|
||||
tx: Prisma.TransactionClient,
|
||||
userId: string,
|
||||
cardType: Pick<CardType, 'id' | 'type' | 'totalTimes' | 'durationDays'>,
|
||||
now = new Date(),
|
||||
): Promise<Membership> {
|
||||
const existing =
|
||||
cardType.type === CardTypeCategory.TRIAL
|
||||
? null
|
||||
: await tx.membership.findFirst({
|
||||
where: { userId, cardTypeId: cardType.id },
|
||||
orderBy: { expireDate: 'desc' },
|
||||
})
|
||||
|
||||
const grant = computeMembershipGrant({
|
||||
existing,
|
||||
cardType: {
|
||||
type: cardType.type,
|
||||
totalTimes: cardType.totalTimes,
|
||||
durationDays: cardType.durationDays,
|
||||
},
|
||||
now,
|
||||
})
|
||||
|
||||
if (!grant.isRenewal || !existing) {
|
||||
return tx.membership.create({
|
||||
data: {
|
||||
userId,
|
||||
cardTypeId: cardType.id,
|
||||
startDate: now,
|
||||
expireDate: grant.expireDate,
|
||||
remainingTimes: grant.remainingTimes,
|
||||
totalTimes: grant.totalTimes,
|
||||
status: MembershipStatus.ACTIVE,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return tx.membership.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
remainingTimes: grant.remainingTimes,
|
||||
totalTimes: grant.totalTimes,
|
||||
expireDate: grant.expireDate,
|
||||
status: MembershipStatus.ACTIVE,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Admin ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async getAllCardTypes(): Promise<CardType[]> {
|
||||
|
||||
@@ -5,6 +5,8 @@ import { MembershipStatus, OrderStatus } from '@mp-pilates/shared'
|
||||
import { PaymentService } from '../payment.service'
|
||||
import { WechatPayService } from '../wechat-pay.service'
|
||||
import { PrismaService } from '../../prisma/prisma.service'
|
||||
import { InviteService } from '../../invite/invite.service'
|
||||
import { MembershipService } from '../../membership/membership.service'
|
||||
|
||||
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -18,6 +20,7 @@ const mockCardType = {
|
||||
type: 'TIMES',
|
||||
originalPrice: null,
|
||||
description: null,
|
||||
coverUrl: null,
|
||||
sortOrder: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
@@ -35,6 +38,11 @@ const mockUser = {
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
const mockInviteService = {
|
||||
validateInviterForTrialOrder: jest.fn(),
|
||||
recordTrialOrderPaid: jest.fn(),
|
||||
}
|
||||
|
||||
const buildMockOrder = (overrides: Partial<Record<string, unknown>> = {}) => ({
|
||||
id: 'order-uuid-1',
|
||||
userId: mockUser.id,
|
||||
@@ -76,6 +84,11 @@ function buildPrismaMock() {
|
||||
},
|
||||
membership: {
|
||||
create: jest.fn(),
|
||||
findFirst: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
flashSaleOrder: {
|
||||
updateMany: jest.fn(),
|
||||
},
|
||||
$transaction: jest.fn(),
|
||||
}
|
||||
@@ -103,8 +116,10 @@ describe('PaymentService', () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
PaymentService,
|
||||
MembershipService,
|
||||
{ provide: PrismaService, useValue: prisma },
|
||||
{ provide: WechatPayService, useValue: wechat },
|
||||
{ provide: InviteService, useValue: mockInviteService },
|
||||
],
|
||||
}).compile()
|
||||
|
||||
@@ -161,6 +176,16 @@ describe('PaymentService', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('validates inviter relationship for trial card orders', async () => {
|
||||
prisma.cardType.findUnique.mockResolvedValue({ ...mockCardType, type: 'TRIAL' })
|
||||
prisma.user.findUnique.mockResolvedValue(mockUser)
|
||||
prisma.order.create.mockResolvedValue(buildMockOrder())
|
||||
|
||||
await service.createOrder(mockUser.id, mockCardType.id, 'inviter-001')
|
||||
|
||||
expect(mockInviteService.validateInviterForTrialOrder).toHaveBeenCalledWith(mockUser.id, 'inviter-001')
|
||||
})
|
||||
|
||||
it('throws NotFoundException when cardType does not exist', async () => {
|
||||
prisma.cardType.findUnique.mockResolvedValue(null)
|
||||
|
||||
@@ -209,30 +234,33 @@ describe('PaymentService', () => {
|
||||
})
|
||||
prisma.order.findUnique.mockResolvedValue(pendingOrder)
|
||||
prisma.cardType.findUnique.mockResolvedValue(mockCardType)
|
||||
prisma.$transaction.mockResolvedValue([])
|
||||
prisma.membership.findFirst.mockResolvedValue(null)
|
||||
prisma.membership.create.mockResolvedValue({
|
||||
id: 'mem-new-1',
|
||||
userId: pendingOrder.userId,
|
||||
cardTypeId: pendingOrder.cardTypeId,
|
||||
remainingTimes: mockCardType.totalTimes,
|
||||
totalTimes: mockCardType.totalTimes,
|
||||
status: MembershipStatus.ACTIVE,
|
||||
})
|
||||
prisma.$transaction.mockImplementation(async (fn: (tx: typeof prisma) => Promise<unknown>) => fn(prisma))
|
||||
})
|
||||
|
||||
it('marks order as PAID and creates membership on valid callback', async () => {
|
||||
it('marks order as PAID and grants a new membership on valid callback', async () => {
|
||||
const result = await service.handleWxNotify(headers, successBody)
|
||||
|
||||
// $transaction called once with an array of two operations
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1)
|
||||
const [transactionOps] = prisma.$transaction.mock.calls[0] as [unknown[]]
|
||||
expect(transactionOps).toHaveLength(2)
|
||||
|
||||
// order.update was called with PAID status and transaction id
|
||||
expect(prisma.order.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
status: OrderStatus.PAID,
|
||||
wxTransactionId: successBody.transaction_id,
|
||||
membershipId: 'mem-new-1',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
// membership.create was called
|
||||
expect(prisma.membership.create).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(mockInviteService.recordTrialOrderPaid).toHaveBeenCalledWith(pendingOrder.id)
|
||||
expect(result).toContain('SUCCESS')
|
||||
})
|
||||
|
||||
@@ -257,7 +285,7 @@ describe('PaymentService', () => {
|
||||
|
||||
const expectedExpireMs =
|
||||
membershipData.startDate.getTime() + mockCardType.durationDays * 86_400_000
|
||||
expect(membershipData.expireDate.getTime()).toBeCloseTo(expectedExpireMs, -2) // within 100ms
|
||||
expect(membershipData.expireDate.getTime()).toBeCloseTo(expectedExpireMs, -2)
|
||||
expect(membershipData.startDate.getTime()).toBeGreaterThanOrEqual(beforeCall)
|
||||
})
|
||||
|
||||
@@ -267,14 +295,15 @@ describe('PaymentService', () => {
|
||||
expect(prisma.membership.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
remainingTimes: mockCardType.totalTimes, // 10
|
||||
remainingTimes: mockCardType.totalTimes,
|
||||
totalTimes: mockCardType.totalTimes,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('creates membership with null remainingTimes for duration-based cardType', async () => {
|
||||
const durationCardType = { ...mockCardType, totalTimes: null }
|
||||
const durationCardType = { ...mockCardType, totalTimes: null, type: 'DURATION' }
|
||||
prisma.cardType.findUnique.mockResolvedValue(durationCardType)
|
||||
|
||||
await service.handleWxNotify(headers, successBody)
|
||||
@@ -283,6 +312,46 @@ describe('PaymentService', () => {
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
remainingTimes: null,
|
||||
totalTimes: null,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('renews an existing same-type membership instead of creating another', async () => {
|
||||
const existingMembership = {
|
||||
id: 'mem-existing-1',
|
||||
userId: pendingOrder.userId,
|
||||
cardTypeId: pendingOrder.cardTypeId,
|
||||
remainingTimes: 2,
|
||||
totalTimes: 10,
|
||||
expireDate: new Date('2099-01-01T00:00:00Z'),
|
||||
status: MembershipStatus.ACTIVE,
|
||||
}
|
||||
prisma.membership.findFirst.mockResolvedValue(existingMembership)
|
||||
prisma.membership.update.mockResolvedValue({
|
||||
...existingMembership,
|
||||
remainingTimes: 12,
|
||||
totalTimes: 20,
|
||||
})
|
||||
|
||||
await service.handleWxNotify(headers, successBody)
|
||||
|
||||
expect(prisma.membership.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 'mem-existing-1' },
|
||||
data: expect.objectContaining({
|
||||
remainingTimes: 12,
|
||||
totalTimes: 20,
|
||||
status: MembershipStatus.ACTIVE,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(prisma.membership.create).not.toHaveBeenCalled()
|
||||
expect(prisma.order.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
membershipId: 'mem-existing-1',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { IsUUID } from 'class-validator'
|
||||
import { IsOptional, IsUUID } from 'class-validator'
|
||||
|
||||
export class CreateOrderDto {
|
||||
@IsUUID()
|
||||
cardTypeId!: string
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
inviterId?: string
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export class PaymentController {
|
||||
@CurrentUser('sub') userId: string,
|
||||
@Body(new ValidationPipe({ whitelist: true })) dto: CreateOrderDto,
|
||||
) {
|
||||
return this.paymentService.createOrder(userId, dto.cardTypeId)
|
||||
return this.paymentService.createOrder(userId, dto.cardTypeId, dto.inviterId)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,9 +3,11 @@ import { PrismaModule } from '../prisma/prisma.module'
|
||||
import { PaymentService } from './payment.service'
|
||||
import { PaymentController } from './payment.controller'
|
||||
import { WechatPayService } from './wechat-pay.service'
|
||||
import { InviteModule } from '../invite/invite.module'
|
||||
import { MembershipModule } from '../membership/membership.module'
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
imports: [PrismaModule, InviteModule, MembershipModule],
|
||||
controllers: [PaymentController],
|
||||
providers: [PaymentService, WechatPayService],
|
||||
exports: [PaymentService, WechatPayService],
|
||||
|
||||
@@ -5,9 +5,11 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common'
|
||||
import { CardType, Order } from '@prisma/client'
|
||||
import { MembershipStatus, OrderStatus, FlashSaleOrderStatus } from '@mp-pilates/shared'
|
||||
import { OrderStatus, FlashSaleOrderStatus } from '@mp-pilates/shared'
|
||||
import { PrismaService } from '../prisma/prisma.service'
|
||||
import { WechatPayService, WxPaymentParams } from './wechat-pay.service'
|
||||
import { InviteService } from '../invite/invite.service'
|
||||
import { MembershipService } from '../membership/membership.service'
|
||||
|
||||
export interface CreateOrderResult {
|
||||
order: Order
|
||||
@@ -28,11 +30,13 @@ export class PaymentService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly wechatPayService: WechatPayService,
|
||||
private readonly inviteService: InviteService,
|
||||
private readonly membershipService: MembershipService,
|
||||
) {}
|
||||
|
||||
// ─── User: create order ────────────────────────────────────────────────────
|
||||
|
||||
async createOrder(userId: string, cardTypeId: string): Promise<CreateOrderResult> {
|
||||
async createOrder(userId: string, cardTypeId: string, inviterId?: string): Promise<CreateOrderResult> {
|
||||
const cardType = await this.prisma.cardType.findUnique({ where: { id: cardTypeId } })
|
||||
|
||||
if (!cardType) {
|
||||
@@ -47,6 +51,10 @@ export class PaymentService {
|
||||
throw new NotFoundException(`User ${userId} not found`)
|
||||
}
|
||||
|
||||
if (cardType.type === 'TRIAL') {
|
||||
await this.inviteService.validateInviterForTrialOrder(userId, inviterId)
|
||||
}
|
||||
|
||||
const orderNo = `${Date.now()}${Math.random().toString(36).substring(2, 8)}`
|
||||
|
||||
const order = await this.prisma.order.create({
|
||||
@@ -112,30 +120,29 @@ export class PaymentService {
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const expireDate = new Date(now.getTime() + cardType.durationDays * 86_400_000)
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.order.update({
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const membership = await this.membershipService.grantPurchasedCard(
|
||||
tx,
|
||||
existingOrder.userId,
|
||||
cardType,
|
||||
now,
|
||||
)
|
||||
|
||||
await tx.order.update({
|
||||
where: { id: existingOrder.id },
|
||||
data: {
|
||||
status: OrderStatus.PAID,
|
||||
wxTransactionId: notification.wxTransactionId,
|
||||
paidAt: now,
|
||||
membershipId: membership.id,
|
||||
},
|
||||
}),
|
||||
this.prisma.membership.create({
|
||||
data: {
|
||||
userId: existingOrder.userId,
|
||||
cardTypeId: existingOrder.cardTypeId,
|
||||
startDate: now,
|
||||
expireDate,
|
||||
remainingTimes: cardType.totalTimes ?? null,
|
||||
status: MembershipStatus.ACTIVE,
|
||||
},
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
this.logger.log(`Order PAID and Membership created: orderNo=${notification.orderNo}`)
|
||||
await this.inviteService.recordTrialOrderPaid(existingOrder.id)
|
||||
|
||||
this.logger.log(`Order PAID and membership granted: orderNo=${notification.orderNo}`)
|
||||
|
||||
// ── Flash sale order: mark as PAID ──
|
||||
if (existingOrder.flashSaleId) {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { IsIn, IsOptional, IsString } from 'class-validator'
|
||||
import type { StudioAssetType } from '@mp-pilates/shared'
|
||||
|
||||
export class CreateStudioUploadCredentialDto {
|
||||
@IsString()
|
||||
fileName!: string
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
contentType?: string
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['gallery', 'logo', 'banner', 'card-cover'])
|
||||
assetType?: StudioAssetType
|
||||
}
|
||||
170
packages/server/src/studio/studio-upload.service.ts
Normal file
170
packages/server/src/studio/studio-upload.service.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
} from '@nestjs/common'
|
||||
import { ConfigService } from '@nestjs/config'
|
||||
import type {
|
||||
StudioAssetType,
|
||||
StudioUploadCredential,
|
||||
} from '@mp-pilates/shared'
|
||||
import { createHash, createHmac, randomBytes } from 'crypto'
|
||||
import { CreateStudioUploadCredentialDto } from './dto/create-studio-upload-credential.dto'
|
||||
|
||||
const ALLOWED_IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'webp', 'heic', 'heif'])
|
||||
const CONTENT_TYPE_BY_EXTENSION: Record<string, string> = {
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
webp: 'image/webp',
|
||||
heic: 'image/heic',
|
||||
heif: 'image/heif',
|
||||
}
|
||||
const EXTENSION_BY_CONTENT_TYPE = new Map(
|
||||
Object.entries(CONTENT_TYPE_BY_EXTENSION).map(([ext, type]) => [type, ext]),
|
||||
)
|
||||
|
||||
@Injectable()
|
||||
export class StudioUploadService {
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
async createUploadCredential(
|
||||
dto: CreateStudioUploadCredentialDto,
|
||||
): Promise<StudioUploadCredential> {
|
||||
const bucket = this.getRequiredConfig('COS_BUCKET')
|
||||
const region = this.getRequiredConfig('COS_REGION')
|
||||
|
||||
const assetType = dto.assetType ?? 'gallery'
|
||||
const extension = this.resolveExtension(dto.fileName, dto.contentType)
|
||||
const key = this.buildObjectKey(assetType, extension)
|
||||
const uploadUrl = `https://${bucket}.cos.${region}.myqcloud.com`
|
||||
const fileUrl = this.buildFileUrl(key, uploadUrl)
|
||||
const expiresAt = Math.floor(Date.now() / 1000) + this.getDurationSeconds()
|
||||
const formData = this.buildPostPolicy({ bucket, key, expiresAt })
|
||||
|
||||
return {
|
||||
bucket,
|
||||
region,
|
||||
key,
|
||||
uploadUrl,
|
||||
fileUrl,
|
||||
assetType,
|
||||
expiresAt,
|
||||
formData,
|
||||
}
|
||||
}
|
||||
|
||||
private buildPostPolicy(params: {
|
||||
bucket: string
|
||||
key: string
|
||||
expiresAt: number
|
||||
}): Record<string, string> {
|
||||
const secretId = this.getRequiredConfig('COS_SECRET_ID')
|
||||
const secretKey = this.getRequiredConfig('COS_SECRET_KEY')
|
||||
const keyTime = this.buildKeyTime(params.expiresAt)
|
||||
const policy = {
|
||||
expiration: new Date(params.expiresAt * 1000).toISOString(),
|
||||
conditions: [
|
||||
{ bucket: params.bucket },
|
||||
['eq', '$key', params.key],
|
||||
{ success_action_status: '200' },
|
||||
{ 'q-sign-algorithm': 'sha1' },
|
||||
{ 'q-ak': secretId },
|
||||
{ 'q-key-time': keyTime },
|
||||
{ 'q-sign-time': keyTime },
|
||||
['content-length-range', 0, 10 * 1024 * 1024],
|
||||
],
|
||||
}
|
||||
const policyJson = JSON.stringify(policy)
|
||||
const policyBase64 = Buffer.from(policyJson).toString('base64')
|
||||
const signKey = createHmac('sha1', secretKey)
|
||||
.update(keyTime)
|
||||
.digest('hex')
|
||||
const stringToSign = createHash('sha1').update(policyJson).digest('hex')
|
||||
const signature = createHmac('sha1', signKey)
|
||||
.update(stringToSign)
|
||||
.digest('hex')
|
||||
|
||||
return {
|
||||
key: params.key,
|
||||
policy: policyBase64,
|
||||
success_action_status: '200',
|
||||
'q-sign-algorithm': 'sha1',
|
||||
'q-ak': secretId,
|
||||
'q-key-time': keyTime,
|
||||
'q-sign-time': keyTime,
|
||||
'q-signature': signature,
|
||||
}
|
||||
}
|
||||
|
||||
private buildObjectKey(assetType: StudioAssetType, extension: string): string {
|
||||
const prefix = this.getUploadPrefix()
|
||||
const now = new Date()
|
||||
const datePath = [
|
||||
now.getUTCFullYear(),
|
||||
String(now.getUTCMonth() + 1).padStart(2, '0'),
|
||||
String(now.getUTCDate()).padStart(2, '0'),
|
||||
].join('/')
|
||||
const randomSuffix = randomBytes(8).toString('hex')
|
||||
|
||||
return `${prefix}/${assetType}/${datePath}/${Date.now()}-${randomSuffix}.${extension}`
|
||||
}
|
||||
|
||||
private buildFileUrl(key: string, uploadUrl: string): string {
|
||||
const publicBaseUrl = this.configService.get<string>('COS_PUBLIC_BASE_URL')?.trim()
|
||||
const baseUrl = publicBaseUrl || uploadUrl
|
||||
return `${baseUrl.replace(/\/$/, '')}/${key}`
|
||||
}
|
||||
|
||||
private buildKeyTime(expiresAt: number): string {
|
||||
const startTime = Math.floor(Date.now() / 1000) - 5
|
||||
return `${startTime};${expiresAt}`
|
||||
}
|
||||
|
||||
private resolveExtension(fileName: string, contentType?: string): string {
|
||||
const cleanedName = fileName.trim().toLowerCase()
|
||||
const fileExtension = cleanedName.includes('.')
|
||||
? cleanedName.split('.').pop() ?? ''
|
||||
: ''
|
||||
|
||||
if (ALLOWED_IMAGE_EXTENSIONS.has(fileExtension)) {
|
||||
return fileExtension === 'jpeg' ? 'jpg' : fileExtension
|
||||
}
|
||||
|
||||
if (contentType) {
|
||||
const normalizedType = contentType.trim().toLowerCase()
|
||||
const matchedExtension = EXTENSION_BY_CONTENT_TYPE.get(normalizedType)
|
||||
|
||||
if (matchedExtension) {
|
||||
return matchedExtension
|
||||
}
|
||||
}
|
||||
|
||||
throw new BadRequestException('仅支持 jpg、png、webp、heic、heif 图片上传')
|
||||
}
|
||||
|
||||
private getDurationSeconds(): number {
|
||||
const configured = Number(this.configService.get<string>('COS_UPLOAD_DURATION_SECONDS') ?? 1800)
|
||||
|
||||
if (!Number.isFinite(configured) || configured < 300 || configured > 7200) {
|
||||
return 1800
|
||||
}
|
||||
|
||||
return Math.floor(configured)
|
||||
}
|
||||
|
||||
private getUploadPrefix(): string {
|
||||
return (this.configService.get<string>('COS_UPLOAD_PREFIX')?.trim() || 'mp/studio')
|
||||
.replace(/^\/+|\/+$/g, '')
|
||||
}
|
||||
|
||||
private getRequiredConfig(key: string): string {
|
||||
const value = this.configService.get<string>(key)?.trim()
|
||||
|
||||
if (!value) {
|
||||
throw new InternalServerErrorException(`${key} 未配置`)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
UseGuards,
|
||||
} from '@nestjs/common'
|
||||
@@ -9,12 +10,17 @@ import { UserRole } from '@mp-pilates/shared'
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
|
||||
import { Roles } from '../auth/roles.decorator'
|
||||
import { RolesGuard } from '../auth/roles.guard'
|
||||
import { CreateStudioUploadCredentialDto } from './dto/create-studio-upload-credential.dto'
|
||||
import { UpdateStudioDto } from './dto/update-studio.dto'
|
||||
import { StudioService } from './studio.service'
|
||||
import { StudioUploadService } from './studio-upload.service'
|
||||
|
||||
@Controller()
|
||||
export class StudioController {
|
||||
constructor(private readonly studioService: StudioService) {}
|
||||
constructor(
|
||||
private readonly studioService: StudioService,
|
||||
private readonly studioUploadService: StudioUploadService,
|
||||
) {}
|
||||
|
||||
@Get('studio/info')
|
||||
getInfo() {
|
||||
@@ -27,4 +33,11 @@ export class StudioController {
|
||||
updateInfo(@Body() dto: UpdateStudioDto) {
|
||||
return this.studioService.updateInfo(dto)
|
||||
}
|
||||
|
||||
@Post('admin/studio/upload-credentials')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
createUploadCredential(@Body() dto: CreateStudioUploadCredentialDto) {
|
||||
return this.studioUploadService.createUploadCredential(dto)
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user