Compare commits

..

7 Commits

Author SHA1 Message Date
richarjiang
14d7c03b05 perf: 更新微信开发者工具调用 skill 2026-04-19 22:45:43 +08:00
richarjiang
bd3d519b4f feat(app): 新增个人中心课表视图 2026-04-19 22:23:23 +08:00
richarjiang
9575210b06 feat: 支持分享邀请好友功能 2026-04-19 14:12:25 +08:00
richarjiang
b02f38dcc7 feat(card): add cover image support for card types 2026-04-15 23:50:12 +08:00
richarjiang
4dacd908a6 feat: 优化排课管理 2026-04-15 23:25:09 +08:00
richarjiang
6ab16f508a feat: 支持画廊图片更新 2026-04-15 13:58:51 +08:00
richarjiang
7ce7cef77c feat: UI 页面优化 2026-04-14 10:07:31 +08:00
80 changed files with 5307 additions and 1505 deletions

View File

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

338
docs/STUDIO_COS_SETUP.md Normal file
View File

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

View File

@@ -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 {

View File

@@ -30,10 +30,18 @@
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>
<view class="cover-deco cover-deco--1" />
<view class="cover-deco cover-deco--2" />
</template>
</view>
<!-- Card info aligns with card-cover height -->
@@ -178,6 +186,11 @@ function goToAllCards() {
position: relative;
}
.card-cover-img {
width: 100%;
height: 100%;
}
/* Decorative circles */
.cover-deco {
position: absolute;

View File

@@ -1,12 +1,15 @@
<template>
<view class="date-selector">
<view class="date-selector" :class="`date-selector--${variant}`">
<scroll-view class="scroll" scroll-x enhanced :show-scrollbar="false">
<view class="track">
<view
v-for="item in dateRange"
:key="item.date"
class="date-item"
:class="{ active: item.date === modelValue, today: item.isToday }"
:class="[
`date-item--${variant}`,
{ active: item.date === modelValue, today: item.isToday },
]"
@tap="handleSelect(item.date)"
>
<text class="weekday">{{ item.isToday ? '今天' : item.weekday }}</text>
@@ -25,6 +28,7 @@ import { getDateRange } from '../utils/format'
interface Props {
modelValue: string
variant?: 'default' | 'booking'
}
const props = defineProps<Props>()
@@ -47,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,40 @@ 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;
}
}
}
}
}
</style>

View File

@@ -40,7 +40,7 @@ interface MenuItem {
path?: string
isAdmin?: boolean
badge?: string
action?: 'clear' | 'about'
action?: 'clear'
requireAuth?: boolean
}
@@ -49,11 +49,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 +82,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 +118,6 @@ const menuItems = computed<MenuItem[]>(() => {
title: '清除缓存',
action: 'clear',
},
{
key: 'about',
type: 'item',
title: '关于我们',
action: 'about',
},
]
if (props.isAdmin) {
@@ -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 })
}
@@ -235,6 +246,57 @@ function handleTap(item: MenuItem) {
}
}
&--teaching-schedule {
background: rgba(93, 140, 138, 0.12);
&::before {
content: '';
width: 24rpx;
height: 22rpx;
border: 2.5rpx solid #476d72;
border-radius: 6rpx;
box-sizing: border-box;
}
&::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 12rpx;
height: 12rpx;
transform: translate(-30%, -18%) rotate(45deg);
border-top: 2.5rpx solid #476d72;
border-left: 2.5rpx solid #476d72;
}
}
&--invite {
background: rgba(255, 122, 69, 0.12);
&::before {
content: '';
width: 20rpx;
height: 20rpx;
border: 2.5rpx solid #ff7a45;
border-radius: 50%;
position: absolute;
top: 12rpx;
left: 14rpx;
box-sizing: border-box;
box-shadow: 16rpx 8rpx 0 -2rpx rgba(255, 122, 69, 0.95);
}
&::after {
content: '';
position: absolute;
width: 22rpx;
height: 12rpx;
border: 2.5rpx solid #ff7a45;
border-top: none;
border-radius: 0 0 14rpx 14rpx;
left: 17rpx;
bottom: 13rpx;
box-sizing: border-box;
}
}
// 个人信息 — 人形(圆 + 肩弧)
&--info {
background: rgba($brand-color, 0.06);
@@ -290,31 +352,6 @@ function handleTap(item: MenuItem) {
}
}
// 关于我们 — 圆形中心一个点 + 竖线info 标记)
&--about {
background: rgba($text-hint, 0.08);
&::before {
content: '';
width: 22rpx;
height: 22rpx;
border: 2.5rpx solid $text-secondary;
border-radius: 50%;
box-sizing: border-box;
}
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 2.5rpx;
height: 8rpx;
background: $text-secondary;
border-radius: 1rpx;
box-shadow: 0 -6rpx 0 0 $text-secondary;
}
}
// 管理中心 — 齿轮(圆 + 四个刻度)
&--admin {
background: rgba($accent-color, 0.12);
@@ -359,15 +396,15 @@ function handleTap(item: MenuItem) {
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));
color: #8f6759;
background: linear-gradient(135deg, rgba(255, 248, 244, 0.98), rgba(241, 228, 220, 0.96));
border-radius: 999rpx;
padding: 9rpx 18rpx;
margin-right: $spacing-sm;
border: 1rpx solid rgba(123, 165, 190, 0.18);
border: 1rpx solid rgba(192, 154, 137, 0.16);
box-shadow:
inset 0 1rpx 0 rgba(255, 255, 255, 0.92),
0 6rpx 16rpx rgba(123, 165, 190, 0.16);
0 6rpx 16rpx rgba(143, 103, 89, 0.12);
}
&__arrow {

View File

@@ -160,6 +160,7 @@ const isPast = computed(() => isSlotPast(props.timeSlot.date, props.timeSlot.sta
margin: 0 24rpx 20rpx;
min-height: 220rpx;
transition: all 0.2s ease;
filter: drop-shadow(0 16rpx 28rpx rgba(120, 91, 79, 0.08));
&:active {
transform: scale(0.98);
@@ -216,7 +217,7 @@ const isPast = computed(() => isSlotPast(props.timeSlot.date, props.timeSlot.sta
.time-main {
font-size: 40rpx;
font-weight: 800;
color: #1a1a2e;
color: #3a2e2a;
line-height: 1;
letter-spacing: 1rpx;
}
@@ -224,7 +225,7 @@ const isPast = computed(() => isSlotPast(props.timeSlot.date, props.timeSlot.sta
.time-label {
margin-top: 8rpx;
font-size: 22rpx;
color: #999;
color: #a18a82;
font-weight: 500;
}
@@ -248,7 +249,7 @@ const isPast = computed(() => isSlotPast(props.timeSlot.date, props.timeSlot.sta
width: 10rpx;
height: 10rpx;
border-radius: 50%;
background: #ccc;
background: #ccb7ae;
flex-shrink: 0;
}
@@ -257,8 +258,8 @@ const isPast = computed(() => isSlotPast(props.timeSlot.date, props.timeSlot.sta
height: 2rpx;
background: repeating-linear-gradient(
to right,
#d0d0d0 0,
#d0d0d0 8rpx,
#dccbc2 0,
#dccbc2 8rpx,
transparent 8rpx,
transparent 16rpx
);
@@ -269,7 +270,7 @@ const isPast = computed(() => isSlotPast(props.timeSlot.date, props.timeSlot.sta
width: 48rpx;
height: 48rpx;
border-radius: 50%;
background: rgba($primary-color, 0.1);
background: rgba(185, 143, 125, 0.12);
display: flex;
align-items: center;
justify-content: center;
@@ -283,7 +284,7 @@ const isPast = computed(() => isSlotPast(props.timeSlot.date, props.timeSlot.sta
.duration-text {
margin-top: 6rpx;
font-size: 22rpx;
color: #999;
color: #a18a82;
font-weight: 500;
}
@@ -295,34 +296,34 @@ const isPast = computed(() => isSlotPast(props.timeSlot.date, props.timeSlot.sta
font-size: 20rpx;
&.cap-open {
background: rgba(76, 175, 80, 0.08);
background: rgba(101, 163, 126, 0.12);
.capacity-text {
color: #4caf50;
color: #5d9472;
}
}
&.cap-almost {
background: rgba(245, 158, 11, 0.08);
background: rgba(214, 161, 92, 0.14);
.capacity-text {
color: #f59e0b;
color: #b98543;
}
}
&.cap-full {
background: rgba(239, 68, 68, 0.08);
background: rgba(216, 91, 87, 0.12);
.capacity-text {
color: #ef4444;
color: #c96763;
}
}
&.cap-closed {
background: rgba(0, 0, 0, 0.04);
background: rgba(111, 96, 91, 0.08);
.capacity-text {
color: #999;
color: #9d8f89;
}
}
}
@@ -360,7 +361,7 @@ const isPast = computed(() => isSlotPast(props.timeSlot.date, props.timeSlot.sta
.course-name {
font-size: 30rpx;
font-weight: 700;
color: #1a1a2e;
color: #3a2e2a;
letter-spacing: 1rpx;
}
@@ -385,9 +386,9 @@ const isPast = computed(() => isSlotPast(props.timeSlot.date, props.timeSlot.sta
}
.btn-book {
background: linear-gradient(135deg, $primary-color 0%, $primary-dark 100%);
color: #fff;
box-shadow: 0 4rpx 16rpx rgba($primary-dark, 0.3);
background: linear-gradient(135deg, #d5b9ab 0%, #b98f7d 100%);
color: #fffaf7;
box-shadow: 0 8rpx 18rpx rgba(143, 103, 89, 0.24);
min-width: 120rpx;
height: 60rpx;
transition: all 0.15s;
@@ -399,30 +400,30 @@ const isPast = computed(() => isSlotPast(props.timeSlot.date, props.timeSlot.sta
}
.badge-booked {
background: linear-gradient(135deg, $primary-selected-bg, $primary-border);
color: $primary-dark;
background: linear-gradient(135deg, rgba(247, 240, 235, 0.96), rgba(236, 225, 217, 0.98));
color: #8f6759;
}
.badge-expired {
background: #f0f0f0;
color: #999;
background: rgba(111, 96, 91, 0.08);
color: #9d8f89;
}
.badge-full {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
background: rgba(216, 91, 87, 0.12);
color: #c96763;
}
.badge-closed {
background: #f0f0f0;
color: #bbb;
background: rgba(111, 96, 91, 0.08);
color: #b5a8a1;
}
.cancel-link {
font-size: 22rpx;
color: #ef4444;
color: #c96763;
font-weight: 500;
text-decoration: underline;
text-decoration-color: rgba(239, 68, 68, 0.3);
text-decoration-color: rgba(201, 103, 99, 0.28);
}
</style>

View File

@@ -37,22 +37,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) {

View File

@@ -1,10 +1,10 @@
<template>
<view class="time-period-filter">
<view class="time-period-filter" :class="`time-period-filter--${variant}`">
<view
v-for="tab in tabs"
:key="tab.key ?? 'all'"
class="tab-item"
:class="{ active: modelValue === tab.key }"
:class="[`tab-item--${variant}`, { active: modelValue === tab.key }]"
@tap="handleChange(tab.key)"
>
<text class="tab-label">{{ tab.label }}</text>
@@ -25,14 +25,17 @@ interface Tab {
interface Props {
modelValue: PeriodKey
variant?: 'default' | 'booking'
}
defineProps<Props>()
const props = defineProps<Props>()
const emit = defineEmits<{
change: [period: PeriodKey]
'update:modelValue': [period: PeriodKey]
}>()
const variant = computed(() => props.variant ?? 'default')
const tabs = computed<Tab[]>(() => [
{ key: null, label: '全部' },
...Object.entries(TIME_PERIODS).map(([key, val]) => ({
@@ -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,26 @@ 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);
}
}
}
}
}
</style>

View File

@@ -248,11 +248,11 @@ function handleLogin() {
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);
background: linear-gradient(135deg, rgba(255, 249, 245, 0.98), rgba(242, 229, 221, 0.96));
border: 1rpx solid rgba(192, 154, 137, 0.18);
box-shadow:
inset 0 1rpx 0 rgba(255, 255, 255, 0.9),
0 8rpx 20rpx rgba(79, 123, 148, 0.16);
0 8rpx 20rpx rgba(143, 103, 89, 0.14);
}
&__member-icon {
@@ -263,17 +263,17 @@ function handleLogin() {
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(circle at 30% 30%, #ffffff 0%, #dfeef6 38%, #8fb6cb 100%);
background: radial-gradient(circle at 30% 30%, #fffdfb 0%, #f2e2d8 40%, #c79d89 100%);
box-shadow:
inset 0 2rpx 4rpx rgba(255, 255, 255, 0.76),
0 3rpx 8rpx rgba(77, 117, 140, 0.18);
0 3rpx 8rpx rgba(143, 103, 89, 0.16);
&::after {
content: '';
position: absolute;
inset: 3rpx;
border-radius: 50%;
border: 1.5rpx solid rgba(92, 132, 156, 0.35);
border: 1.5rpx solid rgba(143, 103, 89, 0.28);
}
}
@@ -283,7 +283,7 @@ function handleLogin() {
font-size: 20rpx;
line-height: 1;
font-weight: 800;
color: #4f6f82;
color: #8f6759;
letter-spacing: 1rpx;
}
@@ -291,7 +291,7 @@ function handleLogin() {
font-size: 20rpx;
line-height: 1;
font-weight: 700;
color: #537488;
color: #8f6759;
letter-spacing: 2rpx;
}

View File

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

View File

@@ -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": {

View File

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

View File

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

View File

@@ -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: 会员与订单 -->

View File

@@ -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 -->
@@ -404,7 +404,7 @@ function slotBadgeClass(slot: EditableSlot): string {
function slotBadgeText(slot: EditableSlot): string {
if (slot.isNew) return '新增'
if (slot.isPublished) return '已发布'
return '来自模板'
return '默认时段'
}
// ── Lifecycle ─────────────────────────────────────────────

View File

@@ -128,7 +128,7 @@
</picker>
</view>
</view>
<text class="gen-hint">根据排课模板自动生成所选日期范围内的时段</text>
<text class="gen-hint">按默认时间表每天 8:00-22:00每小时一节自动生成所选日期范围内的时段</text>
<view class="action-wrap">
<view class="action-btn" :class="{ 'action-btn--loading': submitting }" @tap="submitGenerate">
<text class="action-btn-text">{{ submitting ? '生成中...' : '批量生成' }}</text>

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -10,8 +10,8 @@
<!-- Date & period filters -->
<view class="filter-header">
<DateSelector v-model="selectedDate" @select="onDateSelect" />
<TimePeriodFilter v-model="selectedPeriod" @change="onPeriodChange" />
<DateSelector v-model="selectedDate" variant="booking" @select="onDateSelect" />
<TimePeriodFilter v-model="selectedPeriod" variant="booking" @change="onPeriodChange" />
</view>
<!-- Slot list -->
@@ -312,7 +312,9 @@ onMounted(async () => {
<style lang="scss" scoped>
.booking-page {
height: 100vh;
background: $primary-bg;
background:
radial-gradient(circle at top, rgba(255, 232, 218, 0.36), transparent 34%),
linear-gradient(180deg, #fbf7f3 0%, #f6efea 100%);
display: flex;
flex-direction: column;
overflow: hidden;
@@ -321,7 +323,7 @@ onMounted(async () => {
/* ── Status bar ───────────────────────────────────── */
.status-bar {
flex-shrink: 0;
background: #fff;
background: #fcfaf8;
}
/* ── Page header ──────────────────────────────────── */
@@ -331,20 +333,21 @@ onMounted(async () => {
display: flex;
align-items: center;
justify-content: center;
background: #fff;
background: #fcfaf8;
}
.page-title {
font-size: 34rpx;
font-weight: 600;
color: #1a1a2e;
font-weight: 700;
color: #3a2e2a;
letter-spacing: 1rpx;
}
/* ── Filter header ────────────────────────────────── */
.filter-header {
flex-shrink: 0;
background: #fff;
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.04);
background: rgba(252, 250, 248, 0.96);
box-shadow: 0 12rpx 30rpx rgba(120, 91, 79, 0.06);
}
/* ── Scroll container ──────────────────────────────── */
@@ -358,7 +361,7 @@ onMounted(async () => {
.slot-list {
display: flex;
flex-direction: column;
padding: 24rpx 0 0;
padding: 28rpx 0 0;
}
/* ── Date summary ──────────────────────────────────── */
@@ -368,8 +371,8 @@ onMounted(async () => {
.date-summary-text {
font-size: 24rpx;
color: #999;
font-weight: 400;
color: #9d8b83;
font-weight: 500;
}
/* ── Loading skeleton ──────────────────────────────── */
@@ -382,22 +385,22 @@ onMounted(async () => {
.skeleton-card {
height: 220rpx;
border-radius: 20rpx;
background: #fff;
border-radius: 26rpx;
background: rgba(255, 255, 255, 0.88);
display: flex;
flex-direction: row;
align-items: center;
padding: 28rpx 48rpx;
gap: 20rpx;
margin: 0 24rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.03);
box-shadow: 0 16rpx 36rpx rgba(120, 91, 79, 0.08);
}
.skeleton-time {
width: 90rpx;
height: 80rpx;
border-radius: 12rpx;
background: linear-gradient(90deg, $primary-border 25%, $primary-light 50%, $primary-border 75%);
background: linear-gradient(90deg, rgba(236, 225, 217, 0.9) 25%, rgba(248, 240, 233, 0.98) 50%, rgba(236, 225, 217, 0.9) 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
flex-shrink: 0;
@@ -414,7 +417,7 @@ onMounted(async () => {
width: 60%;
height: 28rpx;
border-radius: 8rpx;
background: linear-gradient(90deg, $primary-border 25%, $primary-light 50%, $primary-border 75%);
background: linear-gradient(90deg, rgba(236, 225, 217, 0.9) 25%, rgba(248, 240, 233, 0.98) 50%, rgba(236, 225, 217, 0.9) 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
@@ -423,7 +426,7 @@ onMounted(async () => {
width: 40%;
height: 20rpx;
border-radius: 6rpx;
background: linear-gradient(90deg, $primary-border 25%, $primary-light 50%, $primary-border 75%);
background: linear-gradient(90deg, rgba(236, 225, 217, 0.9) 25%, rgba(248, 240, 233, 0.98) 50%, rgba(236, 225, 217, 0.9) 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
@@ -432,7 +435,7 @@ onMounted(async () => {
width: 100rpx;
height: 60rpx;
border-radius: 20rpx;
background: linear-gradient(90deg, $primary-border 25%, $primary-light 50%, $primary-border 75%);
background: linear-gradient(90deg, rgba(236, 225, 217, 0.9) 25%, rgba(248, 240, 233, 0.98) 50%, rgba(236, 225, 217, 0.9) 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
flex-shrink: 0;
@@ -466,14 +469,14 @@ onMounted(async () => {
&.outer {
width: 180rpx;
height: 180rpx;
border: 2rpx solid $primary-border;
border: 2rpx solid rgba(192, 154, 137, 0.18);
animation: breathe 3s ease-in-out infinite;
}
&.inner {
width: 120rpx;
height: 120rpx;
background: linear-gradient(135deg, $primary-light 0%, $primary-color 50%, $primary-dark 100%);
background: linear-gradient(135deg, #f6e9e1 0%, #ddc1b4 50%, #b98f7d 100%);
opacity: 0.6;
animation: breathe 3s ease-in-out infinite 0.5s;
}
@@ -484,7 +487,7 @@ onMounted(async () => {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background: $primary-dark;
background: #a87d6c;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
@@ -493,7 +496,7 @@ onMounted(async () => {
.empty-text {
font-size: 32rpx;
color: $primary-dark;
color: #6f605b;
font-weight: 600;
letter-spacing: 2rpx;
margin-bottom: 16rpx;
@@ -501,7 +504,7 @@ onMounted(async () => {
.empty-sub {
font-size: 26rpx;
color: $primary-color;
color: #a18a82;
letter-spacing: 1rpx;
}

View File

@@ -37,10 +37,18 @@
class="card-row"
@tap="goToDetail(c.id)"
>
<!-- Card Cover clean minimal -->
<view class="card-cover" :class="getCardCoverClass(c.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="c.coverUrl ? '' : getCardCoverClass(c.type)">
<image
v-if="c.coverUrl"
class="card-cover-img"
:src="c.coverUrl"
mode="aspectFill"
/>
<template v-else>
<view class="cover-deco cover-deco--1" />
<view class="cover-deco cover-deco--2" />
</template>
</view>
<!-- Card info aligns with card-cover height -->
@@ -77,10 +85,19 @@
<!-- Card content (single card mode) -->
<template v-else>
<!-- Hero section -->
<view class="card-hero" :class="heroClass">
<!-- Decorative circles -->
<view class="hero-deco hero-deco--1" />
<view class="hero-deco hero-deco--2" />
<view class="card-hero" :class="cardData.coverUrl ? 'hero--custom' : heroClass">
<!-- Cover image background -->
<image
v-if="cardData.coverUrl"
class="hero-cover-img"
:src="cardData.coverUrl"
mode="aspectFill"
/>
<!-- Decorative circles (only when no cover image) -->
<template v-else>
<view class="hero-deco hero-deco--1" />
<view class="hero-deco hero-deco--2" />
</template>
<view class="hero-badge">
<text class="hero-badge-text">{{ typeLabel }}</text>
@@ -315,8 +332,10 @@ async function doPurchase() {
uni.showLoading({ title: '创建订单...' })
try {
const inviterId = uni.getStorageSync('invite_inviter_id') as string
const result = await post<CreateOrderResponse>('/payment/create-order', {
cardTypeId: card.value.id,
inviterId: isTrial.value && inviterId ? inviterId : undefined,
})
uni.hideLoading()
@@ -456,6 +475,18 @@ onMounted(() => {
&.hero--trial {
background: linear-gradient(135deg, #C8D8D2 0%, #A9C4BC 100%);
}
&.hero--custom {
background: #333;
}
}
.hero-cover-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0.5;
}
/* Decorative background circles */
@@ -737,6 +768,11 @@ onMounted(() => {
position: relative;
}
.card-cover-img {
width: 100%;
height: 100%;
}
.cover-deco {
position: absolute;
border-radius: 50%;

View File

@@ -13,8 +13,8 @@
:require-auth="loggedIn"
:active-membership-count="activeMembershipCount"
:upcoming-booking-count="upcomingBookingCount"
:invite-share-eligible="!!user?.inviteShareEligible"
@clear-cache="handleClearCache"
@about="handleAbout"
@require-login="handleLogin"
/>
@@ -129,13 +129,6 @@ function handleClearCache() {
})
}
function handleAbout() {
uni.showModal({
title: '关于我们',
content: 'Focus Core 普拉提工作室\n版本 1.0.0\n\n专注核心遇见更好的自己',
showCancel: false,
})
}
</script>
<style lang="scss" scoped>

View File

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

View File

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

View File

@@ -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,8 @@ import type {
FlashSaleAdminItem,
CreateFlashSaleDto,
UpdateFlashSaleDto,
CreateStudioUploadCredentialDto,
StudioUploadCredential,
} from '@mp-pilates/shared'
interface LegacyPaginatedData<T> {
@@ -84,21 +84,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 +126,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
@@ -262,14 +256,10 @@ export const useAdminStore = defineStore('admin', () => {
return {
// State
weekTemplates,
cardTypes,
studioConfig,
schedulePreview,
scheduleLoading,
// Week templates
fetchWeekTemplates,
saveWeekTemplates,
// Card types
fetchCardTypes,
createCardType,
@@ -278,6 +268,7 @@ export const useAdminStore = defineStore('admin', () => {
// Studio
fetchStudioConfig,
saveStudioConfig,
createStudioUploadCredential,
// Orders
fetchAdminOrders,
// Bookings

View File

@@ -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
@@ -70,6 +73,21 @@ export const useBookingStore = defineStore('booking', () => {
}
}
async function fetchTeachingSchedule(date: string) {
loadingTeachingSchedule.value = true
try {
const result = await get<TeachingScheduleSlot[]>('/admin/teaching-schedule', { date })
teachingSchedule.value = Array.isArray(result) ? result : []
return teachingSchedule.value
} catch (err) {
console.error('Fetch teaching schedule failed:', err)
teachingSchedule.value = []
throw err
} finally {
loadingTeachingSchedule.value = false
}
}
// ─── Admin methods ──────────────────────────────────────────────────────
async function fetchAllAdminBookings(
@@ -124,13 +142,16 @@ export const useBookingStore = defineStore('booking', () => {
slots,
myBookings,
upcomingBookings,
teachingSchedule,
loadingSlots,
loadingBookings,
loadingTeachingSchedule,
fetchSlots,
createBooking,
cancelBooking,
fetchMyBookings,
fetchUpcomingBookings,
fetchTeachingSchedule,
fetchAllAdminBookings,
confirmBooking,
completeBooking,

View File

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

View File

@@ -11,6 +11,10 @@ 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 +32,7 @@ export const useUserStore = defineStore('user', () => {
memberships.value.filter((m) => m.status === MembershipStatus.ACTIVE),
)
const hasValidMembership = computed(() => activeMemberships.value.length > 0)
const inviteShareEligible = computed(() => !!user.value?.inviteShareEligible)
// Actions
async function login() {
@@ -35,7 +40,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 +66,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)
@@ -89,13 +94,13 @@ export const useUserStore = defineStore('user', () => {
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() {
@@ -124,6 +129,7 @@ export const useUserStore = defineStore('user', () => {
isAdmin,
activeMemberships,
hasValidMembership,
inviteShareEligible,
login,
loginWithSetup,
fetchProfile,

View File

@@ -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)

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,7 @@
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:seed": "ts-node prisma/seed.ts",
"studio:seed-gallery": "ts-node prisma/update-studio-gallery.ts",
"lint": "eslint \"{src,test}/**/*.ts\""
},
"dependencies": {

View File

@@ -63,6 +63,12 @@ enum FlashSaleOrderStatus {
EXPIRED
}
enum InviteReferralStatus {
REGISTERED
TRIAL_PURCHASED
QUALIFIED
}
// ===== Models =====
model User {
@@ -82,6 +88,9 @@ model User {
orders Order[]
flashSaleOrders FlashSaleOrder[]
subscriptionMessageConsents SubscriptionMessageConsent[]
sentInviteReferrals InviteReferral[] @relation("InviteReferralInviter")
receivedInviteReferral InviteReferral[] @relation("InviteReferralInvitee")
inviteRewardGrants InviteRewardGrant[] @relation("InviteRewardGrantInviter")
@@map("users")
}
@@ -120,6 +129,7 @@ model CardType {
price Decimal @db.Decimal(10, 0)
originalPrice Decimal? @map("original_price") @db.Decimal(10, 0)
description String?
coverUrl String? @map("cover_url")
isActive Boolean @default(true) @map("is_active")
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at")
@@ -146,6 +156,7 @@ model Membership {
user User @relation(fields: [userId], references: [id])
cardType CardType @relation(fields: [cardTypeId], references: [id])
bookings Booking[]
inviteRewardGrants InviteRewardGrant[]
@@index([userId])
@@index([status])
@@ -205,6 +216,7 @@ model Booking {
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[]
@@ -245,12 +257,54 @@ model Order {
user User @relation(fields: [userId], references: [id])
cardType CardType @relation(fields: [cardTypeId], references: [id])
flashSaleOrder FlashSaleOrder?
inviteReferrals InviteReferral[]
@@index([userId])
@@index([status])
@@map("orders")
}
model InviteReferral {
id String @id @default(uuid())
inviterId String @map("inviter_id")
inviteeId String @unique @map("invitee_id")
status InviteReferralStatus @default(REGISTERED)
trialOrderId String? @unique @map("trial_order_id")
qualifiedBookingId String? @unique @map("qualified_booking_id")
invitedAt DateTime @default(now()) @map("invited_at")
trialPurchasedAt DateTime? @map("trial_purchased_at")
qualifiedAt DateTime? @map("qualified_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
inviter User @relation("InviteReferralInviter", fields: [inviterId], references: [id])
invitee User @relation("InviteReferralInvitee", fields: [inviteeId], references: [id])
trialOrder Order? @relation(fields: [trialOrderId], references: [id])
qualifiedBooking Booking? @relation(fields: [qualifiedBookingId], references: [id])
@@unique([inviterId, inviteeId])
@@index([inviterId, status])
@@index([status])
@@map("invite_referrals")
}
model InviteRewardGrant {
id String @id @default(uuid())
inviterId String @map("inviter_id")
membershipId String? @map("membership_id")
qualifiedReferralCount Int @map("qualified_referral_count")
rewardTimes Int @default(1) @map("reward_times")
grantedAt DateTime @default(now()) @map("granted_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
inviter User @relation("InviteRewardGrantInviter", fields: [inviterId], references: [id])
membership Membership? @relation(fields: [membershipId], references: [id])
@@index([inviterId, grantedAt])
@@map("invite_reward_grants")
}
model StudioConfig {
id String @id @default(uuid())
name String

View File

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

View File

@@ -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],
})

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,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)
@@ -93,15 +148,19 @@ export class AuthService {
sessionKeyStore.set(updated.id, sessionKey)
const payload: JwtPayload = { sub: updated.id, role: updated.role as UserRole }
const token = this.jwtService.sign(payload)
return { token, user: updated, isNewUser: false }
return { token, user: await this.mapLoginUser(updated), isNewUser: false }
}
sessionKeyStore.set(user.id, sessionKey)
if (isNewUser) {
await this.inviteService.bindInviterToUser(user.id, inviterId)
}
const payload: JwtPayload = { sub: user.id, role: user.role as UserRole }
const token = this.jwtService.sign(payload)
return { token, user, isNewUser }
return { token, user: await this.mapLoginUser(user), isNewUser }
}
async bindPhone(

View File

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

View File

@@ -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 ──────────────────────────────────────────────────────────────
@@ -153,6 +154,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 +173,7 @@ describe('BookingService', () => {
},
timeSlot: {
findUnique: jest.fn(),
findMany: jest.fn(),
update: jest.fn(),
},
membership: {
@@ -204,6 +207,12 @@ describe('BookingService', () => {
sendAdminBookingCreatedMessage: jest.fn(),
},
},
{
provide: InviteService,
useValue: {
recordQualifiedTrialBooking: jest.fn(),
},
},
],
}).compile()
@@ -211,6 +220,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())
@@ -262,6 +272,44 @@ describe('BookingService', () => {
})
})
describe('completeBooking', () => {
it('records qualified trial booking after completion', async () => {
const tx = buildTxMock({
bookingStatusHistory: { create: jest.fn() },
})
tx.booking.findUnique.mockResolvedValue({
...mockConfirmedBooking,
status: BookingStatus.CONFIRMED,
timeSlot: mockOpenSlot,
})
tx.booking.update.mockResolvedValue({
...mockConfirmedBooking,
status: BookingStatus.COMPLETED,
completedAt: new Date('2099-12-31T11:00:00Z'),
})
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
...mockConfirmedBooking,
status: BookingStatus.COMPLETED,
completedAt: new Date('2099-12-31T11:00:00Z'),
timeSlot: mockOpenSlot,
membership: {
...mockActiveMembership,
cardType: {
...mockTimesCardType,
type: CardTypeCategory.TRIAL,
},
},
})
await service.completeBooking(MOCK_BOOKING_ID, 'admin-001')
expect(inviteService.recordQualifiedTrialBooking).toHaveBeenCalledWith(MOCK_BOOKING_ID)
})
})
// ─── createBooking ────────────────────────────────────────────────────────
describe('createBooking', () => {
@@ -856,4 +904,101 @@ describe('BookingService', () => {
)
})
})
describe('getTeachingScheduleByDate', () => {
it('returns sorted slots with active students only', async () => {
;(prisma.timeSlot.findMany as jest.Mock).mockResolvedValue([
{
id: 'slot-02',
startTime: '11:00',
endTime: '12:00',
bookedCount: 1,
capacity: 1,
bookings: [
{
id: 'booking-02',
status: BookingStatus.CONFIRMED,
createdAt: new Date('2026-04-19T01:00:00Z'),
user: { id: 'user-02', nickname: '李四', phone: '13800000000' },
},
],
},
{
id: 'slot-01',
startTime: '09:00',
endTime: '10:00',
bookedCount: 2,
capacity: 2,
bookings: [
{
id: 'booking-01',
status: BookingStatus.PENDING_CONFIRMATION,
createdAt: new Date('2026-04-19T00:00:00Z'),
user: { id: 'user-01', nickname: '张三', phone: null },
},
],
},
])
const result = await service.getTeachingScheduleByDate('2026-04-19')
expect(prisma.timeSlot.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
bookings: {
some: {
status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] },
},
},
}),
orderBy: [
{ startTime: 'asc' },
{ endTime: 'asc' },
],
}),
)
expect(result).toEqual([
{
slotId: 'slot-01',
date: '2026-04-19',
startTime: '09:00',
endTime: '10:00',
bookedCount: 2,
capacity: 2,
students: [
{
bookingId: 'booking-01',
userId: 'user-01',
nickname: '张三',
phone: null,
status: BookingStatus.PENDING_CONFIRMATION,
},
],
},
{
slotId: 'slot-02',
date: '2026-04-19',
startTime: '11:00',
endTime: '12:00',
bookedCount: 1,
capacity: 1,
students: [
{
bookingId: 'booking-02',
userId: 'user-02',
nickname: '李四',
phone: '13800000000',
status: BookingStatus.CONFIRMED,
},
],
},
])
})
it('rejects invalid date input', async () => {
await expect(service.getTeachingScheduleByDate('invalid-date')).rejects.toThrow(
BadRequestException,
)
})
})
})

View File

@@ -1,4 +1,5 @@
import {
BadRequestException,
Body,
Controller,
Get,
@@ -91,6 +92,16 @@ export class BookingController {
)
}
@Get('admin/teaching-schedule')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
async getTeachingSchedule(@Query('date') date?: string) {
if (!date) {
throw new BadRequestException('date is required')
}
return this.bookingService.getTeachingScheduleByDate(date)
}
@Put('booking/:id/confirm')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)

View File

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

View File

@@ -6,12 +6,19 @@ 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,
} from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
import { MembershipService } from '../membership/membership.service'
import { StudioService } from '../studio/studio.service'
import { SubscriptionMessageService } from '../user/subscription-message.service'
import { CreateBookingDto } from './dto/create-booking.dto'
import { InviteService } from '../invite/invite.service'
// ─── Types ─────────────────────────────────────────────────────────────────
@@ -50,6 +57,7 @@ export class BookingService {
private readonly membershipService: MembershipService,
private readonly studioService: StudioService,
private readonly subscriptionMessageService: SubscriptionMessageService,
private readonly inviteService: InviteService,
) {}
// ─── Create Booking ──────────────────────────────────────────────────────
@@ -330,7 +338,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 ──────────────────────────────────────────────────────
@@ -576,6 +588,72 @@ export class BookingService {
}
}
async getTeachingScheduleByDate(date: string): Promise<TeachingScheduleSlot[]> {
const dayStart = new Date(`${date}T00:00:00.000Z`)
if (Number.isNaN(dayStart.getTime())) {
throw new BadRequestException('Invalid date')
}
const slots = await this.prisma.timeSlot.findMany({
where: {
date: dayStart,
bookings: {
some: {
status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] },
},
},
},
include: {
bookings: {
where: {
status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] },
},
include: {
user: {
select: {
id: true,
nickname: true,
phone: true,
},
},
},
orderBy: [
{ status: 'asc' },
{ createdAt: 'asc' },
],
},
},
orderBy: [
{ startTime: 'asc' },
{ endTime: 'asc' },
],
})
return slots
.map((slot) => ({
slotId: slot.id,
date,
startTime: slot.startTime,
endTime: slot.endTime,
bookedCount: slot.bookedCount,
capacity: slot.capacity,
students: slot.bookings.map((booking) => ({
bookingId: booking.id,
userId: booking.user.id,
nickname: booking.user.nickname,
phone: booking.user.phone,
status: booking.status as BookingStatus,
})),
}))
.sort((a, b) => {
const byStart = a.startTime.localeCompare(b.startTime)
if (byStart !== 0) {
return byStart
}
return a.endTime.localeCompare(b.endTime)
})
}
// ─── Private Helpers ─────────────────────────────────────────────────────
private async fetchBookingWithRelations(bookingId: string): Promise<BookingWithRelations> {

View File

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

View File

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

View File

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

View 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,
},
})
})
}
}
}

View File

@@ -37,6 +37,10 @@ export class CreateCardTypeDto {
@IsString()
description?: string
@IsOptional()
@IsString()
coverUrl?: string
@IsOptional()
@IsInt()
@Min(0)

View File

@@ -42,6 +42,10 @@ export class UpdateCardTypeDto {
@IsString()
description?: string
@IsOptional()
@IsString()
coverUrl?: string
@IsOptional()
@IsBoolean()
isActive?: boolean

View File

@@ -5,6 +5,7 @@ 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'
// ─── Fixtures ─────────────────────────────────────────────────────────────────
@@ -35,6 +36,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,
@@ -105,6 +111,7 @@ describe('PaymentService', () => {
PaymentService,
{ provide: PrismaService, useValue: prisma },
{ provide: WechatPayService, useValue: wechat },
{ provide: InviteService, useValue: mockInviteService },
],
}).compile()
@@ -161,6 +168,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)
@@ -232,6 +249,7 @@ describe('PaymentService', () => {
// membership.create was called
expect(prisma.membership.create).toHaveBeenCalledTimes(1)
expect(mockInviteService.recordTrialOrderPaid).toHaveBeenCalledWith(pendingOrder.id)
expect(result).toContain('SUCCESS')
})

View File

@@ -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
}

View File

@@ -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)
}
/**

View File

@@ -3,9 +3,10 @@ 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'
@Module({
imports: [PrismaModule],
imports: [PrismaModule, InviteModule],
controllers: [PaymentController],
providers: [PaymentService, WechatPayService],
exports: [PaymentService, WechatPayService],

View File

@@ -8,6 +8,7 @@ import { CardType, Order } from '@prisma/client'
import { MembershipStatus, 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'
export interface CreateOrderResult {
order: Order
@@ -28,11 +29,12 @@ export class PaymentService {
constructor(
private readonly prisma: PrismaService,
private readonly wechatPayService: WechatPayService,
private readonly inviteService: InviteService,
) {}
// ─── 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 +49,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({
@@ -135,6 +141,8 @@ export class PaymentService {
}),
])
await this.inviteService.recordTrialOrderPaid(existingOrder.id)
this.logger.log(`Order PAID and Membership created: orderNo=${notification.orderNo}`)
// ── Flash sale order: mark as PAID ──

View File

@@ -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
}

View 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
}
}

View File

@@ -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)
}
}

View File

@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common'
import { StudioController } from './studio.controller'
import { StudioService } from './studio.service'
import { StudioUploadService } from './studio-upload.service'
@Module({
controllers: [StudioController],
providers: [StudioService],
providers: [StudioService, StudioUploadService],
exports: [StudioService],
})
export class StudioModule {}

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common'
import { StudioConfig } from '@prisma/client'
import { StudioConfig as PrismaStudioConfig } from '@prisma/client'
import { PrismaService } from '../prisma/prisma.service'
import { UpdateStudioDto } from './dto/update-studio.dto'
@@ -7,28 +7,71 @@ import { UpdateStudioDto } from './dto/update-studio.dto'
export class StudioService {
constructor(private readonly prisma: PrismaService) {}
async getInfo(): Promise<StudioConfig> {
async getInfo() {
const existing = await this.prisma.studioConfig.findFirst()
if (existing) {
return existing
return this.normalizeStudioConfig(existing)
}
return this.prisma.studioConfig.create({
const created = await this.prisma.studioConfig.create({
data: {
name: '普拉提工作室',
},
})
return this.normalizeStudioConfig(created)
}
async updateInfo(dto: UpdateStudioDto): Promise<StudioConfig> {
const existing = await this.getInfo()
const updated = await this.prisma.studioConfig.update({
where: { id: existing.id },
data: { ...dto },
async updateInfo(dto: UpdateStudioDto) {
const existing = await this.prisma.studioConfig.findFirst({
select: { id: true },
})
return { ...updated }
const data = {
...dto,
photos: dto.photos ? this.normalizePhotos(dto.photos) : undefined,
}
const record = existing
? await this.prisma.studioConfig.update({
where: { id: existing.id },
data,
})
: await this.prisma.studioConfig.create({
data: { name: '普拉提工作室', ...data },
})
return this.normalizeStudioConfig(record)
}
private normalizeStudioConfig(config: PrismaStudioConfig) {
return {
...config,
latitude: config.latitude == null ? null : Number(config.latitude),
longitude: config.longitude == null ? null : Number(config.longitude),
photos: this.normalizePhotos(config.photos),
}
}
private normalizePhotos(value: unknown): string[] {
if (!Array.isArray(value)) {
return []
}
const deduped = new Set<string>()
value.forEach((item) => {
if (typeof item !== 'string') {
return
}
const trimmed = item.trim()
if (trimmed) {
deduped.add(trimmed)
}
})
return [...deduped]
}
}

View File

@@ -6,40 +6,14 @@ import {
TimeSlotSource,
MembershipStatus,
BookingStatus,
getDefaultTimeSlots,
} from '@mp-pilates/shared'
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Return a Date whose JS getDay() maps to the given ISO weekday (1=Mon…7=Sun) */
function dateForIsoWeekday(isoWeekday: number): Date {
const base = new Date('2026-04-06T00:00:00Z') // Monday
const d = new Date(base)
d.setDate(base.getDate() + (isoWeekday - 1))
return d
}
const makeTemplate = (overrides: Record<string, unknown> = {}) => ({
id: 'tpl-1',
dayOfWeek: 1,
startTime: '09:00',
endTime: '10:00',
capacity: 1,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
})
// ---------------------------------------------------------------------------
// Mock PrismaService
// ---------------------------------------------------------------------------
const mockPrisma = {
weekTemplate: {
findMany: jest.fn(),
},
timeSlot: {
createMany: jest.fn(),
updateMany: jest.fn(),
@@ -77,26 +51,12 @@ describe('SlotGeneratorService', () => {
// -------------------------------------------------------------------------
describe('generateSlots', () => {
it('returns 0 when there are no active templates', async () => {
mockPrisma.weekTemplate.findMany.mockResolvedValueOnce([])
it('creates slots for every day using the default schedule (14 slots per day)', async () => {
const defaultSlots = getDefaultTimeSlots()
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: defaultSlots.length * 7 })
const count = await service.generateSlots(7)
expect(count).toBe(0)
expect(mockPrisma.timeSlot.createMany).not.toHaveBeenCalled()
})
it('creates correct number of slots from templates', async () => {
// 2 templates, both for Monday (ISO 1)
mockPrisma.weekTemplate.findMany.mockResolvedValueOnce([
makeTemplate({ id: 'tpl-1', dayOfWeek: 1, startTime: '09:00', endTime: '10:00' }),
makeTemplate({ id: 'tpl-2', dayOfWeek: 1, startTime: '10:00', endTime: '11:00' }),
])
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 2 })
// Use 7 days — will hit exactly one Monday
const count = await service.generateSlots(7)
expect(mockPrisma.timeSlot.createMany).toHaveBeenCalledTimes(1)
const { data, skipDuplicates } =
mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
@@ -104,23 +64,30 @@ describe('SlotGeneratorService', () => {
skipDuplicates: boolean
}
expect(skipDuplicates).toBe(true)
// Both templates should appear in the batch (may include more days)
const mondaySlots = (
data as Array<{ startTime: string; source: TimeSlotSource }>
).filter(
(s) => s.startTime === '09:00' || s.startTime === '10:00',
)
expect(mondaySlots.length).toBeGreaterThanOrEqual(2)
expect(count).toBe(2)
// 7 days × 14 slots per day = 98
expect(data).toHaveLength(defaultSlots.length * 7)
expect(count).toBe(defaultSlots.length * 7)
})
it('creates 14 slots per day (08:00-22:00 hourly)', async () => {
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 14 })
await service.generateSlots(1)
const { data } = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
data: Array<{ startTime: string; endTime: string }>
}
expect(data).toHaveLength(14)
expect(data[0].startTime).toBe('08:00')
expect(data[0].endTime).toBe('09:00')
expect(data[13].startTime).toBe('21:00')
expect(data[13].endTime).toBe('22:00')
})
it('passes skipDuplicates: true to handle existing date+time combinations', async () => {
mockPrisma.weekTemplate.findMany.mockResolvedValueOnce([
makeTemplate({ dayOfWeek: 1 }),
])
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 0 })
await service.generateSlots(7)
await service.generateSlots(1)
const call = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
skipDuplicates: boolean
@@ -128,54 +95,17 @@ describe('SlotGeneratorService', () => {
expect(call.skipDuplicates).toBe(true)
})
it('maps Sunday (JS getDay()=0) to ISO weekday 7', async () => {
// Template for Sunday (ISO 7)
mockPrisma.weekTemplate.findMany.mockResolvedValueOnce([
makeTemplate({ id: 'tpl-sun', dayOfWeek: 7, startTime: '08:00', endTime: '09:00' }),
])
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 1 })
it('sets source to TEMPLATE for all generated slots', async () => {
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 14 })
// 7 days will cover exactly one Sunday
const count = await service.generateSlots(7)
expect(mockPrisma.timeSlot.createMany).toHaveBeenCalledTimes(1)
const { data } = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
data: Array<{ startTime: string; source: TimeSlotSource }>
}
const sundaySlots = data.filter((s) => s.startTime === '08:00')
expect(sundaySlots.length).toBeGreaterThanOrEqual(1)
expect(sundaySlots[0].source).toBe(TimeSlotSource.TEMPLATE)
expect(count).toBe(1)
})
it('maps Monday (JS getDay()=1) to ISO weekday 1', async () => {
mockPrisma.weekTemplate.findMany.mockResolvedValueOnce([
makeTemplate({ id: 'tpl-mon', dayOfWeek: 1, startTime: '07:00', endTime: '08:00' }),
])
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 1 })
await service.generateSlots(7)
await service.generateSlots(1)
const { data } = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
data: Array<{ startTime: string }>
data: Array<{ source: TimeSlotSource }>
}
const mondaySlots = data.filter((s) => s.startTime === '07:00')
expect(mondaySlots.length).toBeGreaterThanOrEqual(1)
})
it('attaches templateId from the matching template', async () => {
mockPrisma.weekTemplate.findMany.mockResolvedValueOnce([
makeTemplate({ id: 'tpl-xyz', dayOfWeek: 2 }),
])
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 1 })
await service.generateSlots(7)
const { data } = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
data: Array<{ templateId: string }>
for (const slot of data) {
expect(slot.source).toBe(TimeSlotSource.TEMPLATE)
}
const tuesdaySlots = data.filter((s) => s.templateId === 'tpl-xyz')
expect(tuesdaySlots.length).toBeGreaterThanOrEqual(1)
})
})

View File

@@ -6,14 +6,10 @@ import {
BookingStatus,
SLOT_GENERATION_DAYS,
DEFAULT_SLOT_CAPACITY,
getDefaultTimeSlots,
} from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
/** Convert JS getDay() (0=Sun … 6=Sat) to ISO weekday (1=Mon … 7=Sun) */
function toIsoWeekday(jsDay: number): number {
return jsDay === 0 ? 7 : jsDay
}
/** Build a UTC Date for midnight of a local calendar date */
function toUtcMidnight(date: Date): Date {
const d = new Date(date)
@@ -28,20 +24,14 @@ export class SlotGeneratorService {
constructor(private readonly prisma: PrismaService) {}
/**
* Generate time slots for the next `daysAhead` days based on active
* WeekTemplates. Uses `createMany` with `skipDuplicates` so re-runs are safe.
* Generate time slots for the next `daysAhead` days based on the fixed
* default schedule (Mon-Sun, 08:00-22:00 hourly).
* Uses `createMany` with `skipDuplicates` so re-runs are safe.
*
* @returns Number of newly created slots
*/
async generateSlots(daysAhead: number = SLOT_GENERATION_DAYS): Promise<number> {
const templates = await this.prisma.weekTemplate.findMany({
where: { isActive: true },
})
if (templates.length === 0) {
this.logger.log('No active week templates found skipping slot generation')
return 0
}
const defaultSlots = getDefaultTimeSlots()
const tomorrow = new Date()
tomorrow.setDate(tomorrow.getDate() + 1)
@@ -53,27 +43,19 @@ export class SlotGeneratorService {
endTime: string
capacity: number
source: TimeSlotSource
templateId: string
}> = []
for (let offset = 0; offset < daysAhead; offset++) {
const target = new Date(tomorrow)
target.setDate(target.getDate() + offset)
const isoWeekday = toIsoWeekday(target.getDay())
const matchingTemplates = templates.filter(
(t) => t.dayOfWeek === isoWeekday,
)
for (const template of matchingTemplates) {
for (const slot of defaultSlots) {
slotsToCreate.push({
date: toUtcMidnight(target),
startTime: template.startTime,
endTime: template.endTime,
capacity: template.capacity ?? DEFAULT_SLOT_CAPACITY,
startTime: slot.startTime,
endTime: slot.endTime,
capacity: DEFAULT_SLOT_CAPACITY,
source: TimeSlotSource.TEMPLATE,
templateId: template.id,
})
}
}

View File

@@ -19,7 +19,6 @@ import { TimeSlotService } from './time-slot.service'
import { SlotGeneratorService } from './slot-generator.service'
import { QuerySlotsDto } from './dto/query-slots.dto'
import { CreateManualSlotDto } from './dto/create-manual-slot.dto'
import { UpdateWeekTemplateDto } from './dto/week-template.dto'
import { PublishDaySlotsDto } from './dto/publish-day-slots.dto'
// ---------------------------------------------------------------------------
@@ -61,18 +60,6 @@ export class AdminTimeSlotController {
private readonly slotGeneratorService: SlotGeneratorService,
) {}
// Week template management
@Get('week-template')
getWeekTemplates() {
return this.timeSlotService.getWeekTemplates()
}
@Put('week-template')
replaceWeekTemplates(@Body() dto: UpdateWeekTemplateDto) {
return this.timeSlotService.replaceWeekTemplates(dto.templates)
}
// Manual slot management
@Post('time-slot/manual')

View File

@@ -1,5 +1,5 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'
import { TimeSlotStatus, BookingStatus, DEFAULT_SLOT_CAPACITY } from '@mp-pilates/shared'
import { TimeSlotStatus, BookingStatus, DEFAULT_SLOT_CAPACITY, getDefaultTimeSlots } from '@mp-pilates/shared'
import { TimeSlotSource } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
import type { TimeSlotWithBookingStatus, ScheduleSlotPreview } from '@mp-pilates/shared'
@@ -144,51 +144,12 @@ export class TimeSlotService {
})
}
async getWeekTemplates() {
return this.prisma.weekTemplate.findMany({
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
})
}
async replaceWeekTemplates(
items: Array<{
dayOfWeek: number
startTime: string
endTime: string
capacity?: number
isActive?: boolean
}>,
) {
return this.prisma.$transaction(async (tx) => {
await tx.weekTemplate.deleteMany()
await tx.weekTemplate.createMany({
data: items.map((item) => ({
dayOfWeek: item.dayOfWeek,
startTime: item.startTime,
endTime: item.endTime,
capacity: item.capacity ?? DEFAULT_SLOT_CAPACITY,
isActive: item.isActive ?? true,
})),
})
return tx.weekTemplate.findMany({
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
})
})
}
// ── Schedule preview & publish ──────────────────────────────
/** Convert JS getDay() (0=Sun … 6=Sat) to ISO weekday (1=Mon … 7=Sun) */
private toIsoWeekday(jsDay: number): number {
return jsDay === 0 ? 7 : jsDay
}
/**
* Return a schedule preview for a given date.
* If TimeSlot records already exist → return them (isPublished: true).
* Otherwise → derive from active WeekTemplates (isPublished: false).
* Otherwise → derive from the fixed default schedule (isPublished: false).
*/
async getSchedulePreview(date: string): Promise<ScheduleSlotPreview[]> {
const parsedDate = new Date(date)
@@ -216,23 +177,19 @@ export class TimeSlotService {
}))
}
// 2. No existing slots — derive from WeekTemplate
const isoWeekday = this.toIsoWeekday(parsedDate.getUTCDay())
const templates = await this.prisma.weekTemplate.findMany({
where: { dayOfWeek: isoWeekday, isActive: true },
orderBy: { startTime: 'asc' },
})
// 2. No existing slots — use fixed default schedule
const defaultSlots = getDefaultTimeSlots()
return templates.map((tpl) => ({
return defaultSlots.map((slot) => ({
id: null,
date: date,
startTime: tpl.startTime,
endTime: tpl.endTime,
capacity: tpl.capacity,
startTime: slot.startTime,
endTime: slot.endTime,
capacity: DEFAULT_SLOT_CAPACITY,
bookedCount: 0,
status: TimeSlotStatus.OPEN,
source: TimeSlotSource.TEMPLATE,
templateId: tpl.id,
templateId: null,
isPublished: false,
}))
}

View File

@@ -71,6 +71,7 @@ export class UserService {
avatarUrl: user.avatarUrl,
role: user.role as UserRole,
activeMembershipCount: user._count.memberships,
inviteShareEligible: user._count.memberships > 0,
adminBookingSubscriptionCount: user.adminBookingSubscriptionCount,
subscriptionMessageTemplates: this.buildSubscriptionTemplateConfig(),
createdAt: user.createdAt.toISOString(),

View File

@@ -2,8 +2,10 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "../..",
"module": "CommonJS",
"moduleResolution": "node",
"ignoreDeprecations": "5.0",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"target": "ES2021",

File diff suppressed because one or more lines are too long

View File

@@ -1,12 +1,35 @@
/** 默认免费取消截止小时数 */
export const DEFAULT_CANCEL_HOURS_LIMIT = 2
/** 默认工作室画廊图片 */
export const DEFAULT_STUDIO_GALLERY_PHOTOS = [
'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',
] as const
/** 默认时段容量(私教 = 1 */
export const DEFAULT_SLOT_CAPACITY = 1
/** 自动生成时段的天数范围 */
export const SLOT_GENERATION_DAYS = 14
/** 默认排课时间表:每天 08:00-22:00每小时一节课 */
export const DEFAULT_SCHEDULE_START_HOUR = 8
export const DEFAULT_SCHEDULE_END_HOUR = 22
/** 生成默认时段列表 (startTime, endTime) */
export function getDefaultTimeSlots(): ReadonlyArray<{ readonly startTime: string; readonly endTime: string }> {
const slots: Array<{ startTime: string; endTime: string }> = []
for (let h = DEFAULT_SCHEDULE_START_HOUR; h < DEFAULT_SCHEDULE_END_HOUR; h++) {
const startTime = String(h).padStart(2, '0') + ':00'
const endTime = String(h + 1).padStart(2, '0') + ':00'
slots.push({ startTime, endTime })
}
return slots
}
/** 时段筛选区间 */
export const TIME_PERIODS = {
MORNING: { label: '上午', start: '06:00', end: '12:00' },

View File

@@ -59,6 +59,13 @@ export enum FlashSaleOrderStatus {
EXPIRED = 'EXPIRED',
}
// ===== Invite =====
export enum InviteReferralStatus {
REGISTERED = 'REGISTERED',
TRIAL_PURCHASED = 'TRIAL_PURCHASED',
QUALIFIED = 'QUALIFIED',
}
// ===== Subscribe Message =====
export enum SubscriptionMessageScene {
ORDER_PAID = 'ORDER_PAID',

View File

@@ -9,14 +9,19 @@ export {
OrderStatus,
FlashSaleStatus,
FlashSaleOrderStatus,
InviteReferralStatus,
SubscriptionMessageScene,
} from './enums'
// Constants
export {
DEFAULT_CANCEL_HOURS_LIMIT,
DEFAULT_STUDIO_GALLERY_PHOTOS,
DEFAULT_SLOT_CAPACITY,
SLOT_GENERATION_DAYS,
DEFAULT_SCHEDULE_START_HOUR,
DEFAULT_SCHEDULE_END_HOUR,
getDefaultTimeSlots,
TIME_PERIODS,
DATE_SELECTOR_DAYS,
WEEKDAY_LABELS,
@@ -45,6 +50,8 @@ export type {
Booking,
BookingWithDetails,
BookingWithUser,
TeachingScheduleStudent,
TeachingScheduleSlot,
BookingStatusHistory,
CreateBookingDto,
Order,
@@ -53,7 +60,10 @@ export type {
PaymentParams,
CreateOrderResponse,
StudioConfig,
StudioAssetType,
UpdateStudioConfigDto,
CreateStudioUploadCredentialDto,
StudioUploadCredential,
ApiResponse,
PaginatedData,
PaginatedResponse,
@@ -65,6 +75,9 @@ export type {
CreateFlashSaleDto,
UpdateFlashSaleDto,
FlashSalePurchaseResponse,
InviteActivityReferral,
InviteRewardGrantRecord,
InviteActivitySummary,
SubscriptionMessageRequestResult,
SubscriptionMessageRequestItem,
SubscriptionMessageTemplate,

View File

@@ -37,6 +37,24 @@ export interface BookingWithUser extends BookingWithDetails {
}
}
export interface TeachingScheduleStudent {
readonly bookingId: string
readonly userId: string
readonly nickname: string
readonly phone: string | null
readonly status: BookingStatus
}
export interface TeachingScheduleSlot {
readonly slotId: string
readonly date: string
readonly startTime: string
readonly endTime: string
readonly bookedCount: number
readonly capacity: number
readonly students: readonly TeachingScheduleStudent[]
}
export interface BookingStatusHistory {
readonly id: string
readonly bookingId: string

View File

@@ -9,6 +9,7 @@ export interface CardType {
readonly price: number
readonly originalPrice: number | null
readonly description: string | null
readonly coverUrl: string | null
readonly isActive: boolean
readonly sortOrder: number
readonly createdAt: string
@@ -23,6 +24,7 @@ export interface CreateCardTypeDto {
readonly price: number
readonly originalPrice?: number
readonly description?: string
readonly coverUrl?: string
readonly sortOrder?: number
}

View File

@@ -11,9 +11,23 @@ export type { CardType, CreateCardTypeDto, UpdateCardTypeDto } from './card-type
export type { Membership, MembershipWithCardType } from './membership'
export type { WeekTemplate, WeekTemplateInput } from './week-template'
export type { TimeSlot, TimeSlotWithBookingStatus, CreateManualSlotDto, ScheduleSlotPreview, PublishDaySlotItem, PublishDaySlotsDto } from './time-slot'
export type { Booking, BookingWithDetails, BookingWithUser, BookingStatusHistory, CreateBookingDto } from './booking'
export type {
Booking,
BookingWithDetails,
BookingWithUser,
TeachingScheduleStudent,
TeachingScheduleSlot,
BookingStatusHistory,
CreateBookingDto,
} from './booking'
export type { Order, OrderWithDetails, CreateOrderDto, PaymentParams, CreateOrderResponse } from './order'
export type { StudioConfig, UpdateStudioConfigDto } from './studio'
export type {
StudioConfig,
StudioAssetType,
UpdateStudioConfigDto,
CreateStudioUploadCredentialDto,
StudioUploadCredential,
} from './studio'
export type { ApiResponse, PaginatedData, PaginatedResponse, PaginationQuery } from './api'
export type {
FlashSale,
@@ -24,4 +38,9 @@ export type {
UpdateFlashSaleDto,
FlashSalePurchaseResponse,
} from './flash-sale'
export type {
InviteActivityReferral,
InviteRewardGrantRecord,
InviteActivitySummary,
} from './invite'
export { FlashSalePhase } from './flash-sale'

View File

@@ -0,0 +1,36 @@
import { InviteReferralStatus } from '../enums'
export interface InviteActivityReferral {
readonly id: string
readonly inviteeId: string
readonly inviteeNickname: string
readonly inviteeAvatarUrl: string | null
readonly status: InviteReferralStatus
readonly invitedAt: string
readonly trialPurchasedAt: string | null
readonly qualifiedAt: string | null
}
export interface InviteRewardGrantRecord {
readonly id: string
readonly membershipId: string | null
readonly qualifiedReferralCount: number
readonly rewardTimes: number
readonly grantedAt: string
}
export interface InviteActivitySummary {
readonly inviterId: string
readonly canInvite: boolean
readonly sharePath: string
readonly rewardRuleInvitesRequired: number
readonly rewardRuleTimes: number
readonly qualifiedInviteCount: number
readonly rewardedTimes: number
readonly pendingRewardGrantCount: number
readonly pendingInviteCount: number
readonly currentCycleQualifiedCount: number
readonly nextRewardRemainingCount: number
readonly referrals: readonly InviteActivityReferral[]
readonly rewardGrants: readonly InviteRewardGrantRecord[]
}

View File

@@ -26,6 +26,7 @@ export interface OrderWithDetails extends Order {
export interface CreateOrderDto {
readonly cardTypeId: string
readonly inviterId?: string
}
export interface PaymentParams {

View File

@@ -12,6 +12,8 @@ export interface StudioConfig {
readonly updatedAt: string
}
export type StudioAssetType = 'gallery' | 'logo' | 'banner' | 'card-cover'
export interface UpdateStudioConfigDto {
readonly name?: string
readonly logo?: string
@@ -23,3 +25,20 @@ export interface UpdateStudioConfigDto {
readonly cancelHoursLimit?: number
readonly photos?: string[]
}
export interface CreateStudioUploadCredentialDto {
readonly fileName: string
readonly contentType?: string
readonly assetType?: StudioAssetType
}
export interface StudioUploadCredential {
readonly bucket: string
readonly region: string
readonly key: string
readonly uploadUrl: string
readonly fileUrl: string
readonly assetType: StudioAssetType
readonly expiresAt: number
readonly formData: Readonly<Record<string, string>>
}

View File

@@ -20,6 +20,7 @@ export interface UserProfileResponse {
readonly avatarUrl: string | null
readonly role: UserRole
readonly activeMembershipCount: number
readonly inviteShareEligible: boolean
readonly adminBookingSubscriptionCount: number
readonly subscriptionMessageTemplates: SubscriptionMessageTemplateConfig
readonly createdAt: string