23 Commits

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

@@ -23,5 +23,5 @@
"prisma" "prisma"
] ]
}, },
"version": "0.0.1" "version": "0.0.2"
} }

View File

@@ -96,6 +96,11 @@
</text> </text>
</view> </view>
<!-- Subscribe note -->
<view class="subscribe-tip">
<text class="subscribe-tip-text">🔔 确认预约将同步订阅约课结果课前1小时提醒与取消通知</text>
</view>
<!-- Action buttons --> <!-- Action buttons -->
<view class="action-row"> <view class="action-row">
<view class="btn-outline" @tap="handleCancel"> <view class="btn-outline" @tap="handleCancel">
@@ -159,9 +164,7 @@ async function handleConfirm() {
try { try {
await requestBookingCreatedSubscriptionMessage() await requestBookingCreatedSubscriptionMessage()
} catch (err: unknown) { } catch (err: unknown) {
const message = err instanceof Error ? err.message : '订阅消息授权失败' console.warn('[subscribe] booking confirm failed', err)
uni.showToast({ title: message, icon: 'none' })
return
} finally { } finally {
requestingSubscribe.value = false requestingSubscribe.value = false
} }
@@ -212,6 +215,8 @@ function handleMaskTap() {
.no-card-text { font-size: 24rpx; color: #8b817b; } .no-card-text { font-size: 24rpx; color: #8b817b; }
.deduction-tip { padding: 18rpx 4rpx; } .deduction-tip { padding: 18rpx 4rpx; }
.deduction-text { font-size: 22rpx; color: #8b817b; line-height: 1.6; } .deduction-text { font-size: 22rpx; color: #8b817b; line-height: 1.6; }
.subscribe-tip { padding: 0 4rpx 14rpx; text-align: center; }
.subscribe-tip-text { font-size: 21rpx; color: #7f8a7e; }
.action-row { display: flex; gap: 20rpx; margin-top: 12rpx; } .action-row { display: flex; gap: 20rpx; margin-top: 12rpx; }
.btn-outline { flex: 1; height: 88rpx; border-radius: 999rpx; background: #f0eae4; display: flex; align-items: center; justify-content: center; } .btn-outline { flex: 1; height: 88rpx; border-radius: 999rpx; background: #f0eae4; display: flex; align-items: center; justify-content: center; }
.btn-outline-text { font-size: 28rpx; color: #78675c; font-weight: 400; } .btn-outline-text { font-size: 28rpx; color: #78675c; font-weight: 400; }

View File

@@ -58,7 +58,7 @@
<text class="card-times-unit">课时</text> <text class="card-times-unit">课时</text>
</view> </view>
<view class="price-row"> <view class="price-row">
<text class="price-current">¥{{ formatPrice(card.price) }}</text> <text class="price-current">¥{{ formatPrice(invite.price(card.price)) }}</text>
<text <text
v-if="card.originalPrice && card.originalPrice > card.price" v-if="card.originalPrice && card.originalPrice > card.price"
class="price-original" class="price-original"
@@ -69,6 +69,7 @@
</view> </view>
</view> </view>
<text v-if="invite.eligible" class="renew-tag">95 </text>
<!-- Arrow --> <!-- Arrow -->
<text class="card-arrow"></text> <text class="card-arrow"></text>
</view> </view>
@@ -82,6 +83,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useInviteStore } from '../stores/invite'
import { computed, ref, onMounted } from 'vue' import { computed, ref, onMounted } from 'vue'
import type { CardType } from '@mp-pilates/shared' import type { CardType } from '@mp-pilates/shared'
import { CardTypeCategory } from '@mp-pilates/shared' import { CardTypeCategory } from '@mp-pilates/shared'
@@ -89,6 +91,7 @@ import { get } from '../utils/request'
import { formatPrice, getCardCoverClass } from '../utils/format' import { formatPrice, getCardCoverClass } from '../utils/format'
import { useUserStore } from '../stores/user' import { useUserStore } from '../stores/user'
const invite = useInviteStore()
const userStore = useUserStore() const userStore = useUserStore()
const cardTypes = ref<CardType[]>([]) const cardTypes = ref<CardType[]>([])
const loading = ref(false) const loading = ref(false)

View File

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

View File

@@ -1,182 +0,0 @@
<template>
<view v-if="flashSales.length" class="flash-sale-section">
<!-- Section header -->
<view class="section-header">
<view class="header-left">
<text class="section-title">限时秒杀</text>
<text v-if="hasOngoing" class="live-note">进行中</text>
</view>
</view>
<!-- Horizontal scroll cards -->
<scroll-view
scroll-x
:show-scrollbar="false"
class="flash-scroll"
>
<view class="flash-card-list">
<view
v-for="sale in flashSales"
:key="sale.id"
class="flash-card"
:class="cardPhaseClass(sale.phase)"
@tap="goToDetail(sale.id)"
>
<!-- Top gradient band -->
<view class="card-top">
<!-- Phase badge -->
<view class="phase-badge" :class="badgeClass(sale.phase)">
<text class="phase-badge-text">{{ phaseLabel(sale.phase) }}</text>
</view>
<!-- Countdown / status text -->
<view class="countdown-row">
<text v-if="sale.phase === FlashSalePhase.UPCOMING" class="countdown-label">距开始</text>
<text v-else-if="sale.phase === FlashSalePhase.ONGOING" class="countdown-label">剩余</text>
<view v-if="sale.phase === FlashSalePhase.UPCOMING || sale.phase === FlashSalePhase.ONGOING" class="countdown-blocks">
<text class="cd-block">{{ getSaleCountdown(sale).h }}</text>
<text class="cd-sep">:</text>
<text class="cd-block">{{ getSaleCountdown(sale).m }}</text>
<text class="cd-sep">:</text>
<text class="cd-block">{{ getSaleCountdown(sale).s }}</text>
</view>
</view>
</view>
<!-- Card body -->
<view class="card-body">
<text class="card-title">{{ sale.title }}</text>
<text class="card-type-name">{{ sale.cardType.name }}</text>
<!-- Price area -->
<view class="price-area">
<view class="flash-price-row">
<text class="flash-currency">¥</text>
<text class="flash-price">{{ formatPrice(sale.flashPrice) }}</text>
</view>
<text class="original-price">¥{{ formatPrice(sale.originalPrice) }}</text>
</view>
<!-- Stock progress -->
<view class="stock-area">
<view class="stock-bar">
<view
class="stock-fill"
:class="{ 'stock-fill--hot': getStockRatio(sale.soldCount, sale.totalStock) > 0.6 }"
:style="{ width: stockPercent(sale) }"
/>
</view>
<text class="stock-text">
{{ sale.phase === FlashSalePhase.SOLD_OUT ? '已售罄' : `${sale.remainingStock}/${sale.totalStock}` }}
</text>
</view>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { FlashSalePhase } from '@mp-pilates/shared'
import type { FlashSaleListItem } from '@mp-pilates/shared'
import { formatPrice, getFlashSalePhaseLabel, getCountdownParts, getStockRatio, getStockPercent } from '../utils/format'
import { get } from '../utils/request'
const flashSales = ref<FlashSaleListItem[]>([])
const tick = ref(0)
let timer: ReturnType<typeof setInterval> | null = null
const hasOngoing = computed(() =>
flashSales.value.some((s) => s.phase === FlashSalePhase.ONGOING),
)
async function fetchFlashSales() {
try {
const data = await get<FlashSaleListItem[]>('/flash-sales')
flashSales.value = [...data]
} catch {
flashSales.value = []
}
}
// Expose for parent page refresh
defineExpose({ fetchFlashSales })
function phaseLabel(phase: FlashSalePhase): string {
return getFlashSalePhaseLabel(phase)
}
function cardPhaseClass(phase: FlashSalePhase): string {
if (phase === FlashSalePhase.ONGOING) return 'card--ongoing'
if (phase === FlashSalePhase.UPCOMING) return 'card--upcoming'
if (phase === FlashSalePhase.SOLD_OUT) return 'card--soldout'
return 'card--ended'
}
function badgeClass(phase: FlashSalePhase): string {
if (phase === FlashSalePhase.ONGOING) return 'badge--ongoing'
if (phase === FlashSalePhase.UPCOMING) return 'badge--upcoming'
return 'badge--inactive'
}
function stockPercent(sale: FlashSaleListItem): string {
return getStockPercent(sale.soldCount, sale.totalStock)
}
function getSaleCountdown(sale: FlashSaleListItem) {
void tick.value
const target = sale.phase === FlashSalePhase.UPCOMING ? sale.startTime : sale.endTime
return getCountdownParts(target)
}
function goToDetail(id: string) {
uni.navigateTo({ url: `/pages/flash-sale/detail?id=${id}` })
}
onMounted(() => {
fetchFlashSales()
timer = setInterval(() => {
tick.value++
}, 1000)
})
onUnmounted(() => {
if (timer) {
clearInterval(timer)
timer = null
}
})
</script>
<style lang="scss" scoped>
.flash-sale-section { margin: 36rpx 32rpx 0; }
.section-header { margin-bottom: 20rpx; }
.header-left { display: flex; align-items: baseline; justify-content: space-between; gap: 16rpx; }
.section-title { font-size: 30rpx; font-weight: 500; color: #514943; }
.live-note { font-size: 22rpx; color: #9b7b66; }
.flash-scroll { width: 100%; white-space: nowrap; }
.flash-card-list { display: inline-flex; gap: 20rpx; }
.flash-card { width: 400rpx; border-radius: 28rpx; overflow: hidden; border: 1rpx solid #e7ded5; flex-shrink: 0; display: inline-flex; flex-direction: column; white-space: normal; background: #fff; }
.card-top { padding: 20rpx 24rpx; background: #f1e6de; display: flex; flex-direction: column; gap: 12rpx; }
.card--upcoming .card-top { background: #eaf0e8; }
.card--soldout .card-top, .card--ended .card-top { background: #eeeae5; }
.phase-badge-text { font-size: 22rpx; color: #7c6656; }
.countdown-row, .countdown-blocks { display: flex; align-items: baseline; gap: 8rpx; }
.countdown-label { font-size: 20rpx; color: #8b817b; }
.cd-block, .cd-sep { font-size: 24rpx; color: #7c6656; font-variant-numeric: tabular-nums; }
.card-body { padding: 24rpx; display: flex; flex-direction: column; gap: 10rpx; }
.card-title { font-size: 28rpx; font-weight: 500; color: #514943; line-height: 1.5; overflow-wrap: anywhere; }
.card-type-name { font-size: 22rpx; color: #8b817b; }
.price-area { display: flex; align-items: baseline; flex-wrap: wrap; gap: 12rpx; margin-top: 8rpx; }
.flash-price-row { display: flex; align-items: baseline; gap: 4rpx; }
.flash-currency { font-size: 22rpx; color: #8b6c5b; }
.flash-price { font-size: 38rpx; font-weight: 500; color: #8b6c5b; }
.original-price { font-size: 21rpx; color: #a59b93; text-decoration: line-through; }
.stock-area { margin-top: 12rpx; display: flex; flex-direction: column; gap: 10rpx; }
.stock-bar { height: 6rpx; background: #f3efea; border-radius: 6rpx; overflow: hidden; }
.stock-fill { height: 100%; border-radius: 6rpx; background: #bea28e; }
.stock-text { font-size: 20rpx; color: #8b817b; }
</style>

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -1,21 +1,9 @@
<template> <template>
<view class="profile-menu"> <view class="profile-menu">
<view class="profile-menu__shortcuts">
<view v-for="item in primaryItems" :key="item.key" class="profile-menu__shortcut"
hover-class="profile-menu__item--hover" @tap="handleTap(item)">
<view class="profile-menu__shortcut-head">
<view class="profile-menu__icon" :class="'profile-menu__icon--' + item.key" />
<text class="profile-menu__arrow"></text>
</view>
<text class="profile-menu__shortcut-title">{{ item.title }}</text>
<text class="profile-menu__shortcut-note">{{ !requireAuth ? '登录后查看' : item.key === 'membership' ? `${activeMembershipCount || 0} 张可用` : `${upcomingBookingCount || 0} 节待上` }}</text>
</view>
</view>
<slot /> <slot />
<view class="profile-menu__links"> <view class="profile-menu__links">
<template v-for="item in secondaryItems" :key="item.key"> <template v-for="item in menuItems" :key="item.key">
<view v-if="item.type === 'separator'" class="profile-menu__separator" /> <view v-if="item.type === 'separator'" class="profile-menu__separator" />
<view v-else class="profile-menu__item" :class="{ 'profile-menu__item--admin': item.isAdmin }" <view v-else class="profile-menu__item" :class="{ 'profile-menu__item--admin': item.isAdmin }"
hover-class="profile-menu__item--hover" hover-stay-time="150" @tap="handleTap(item)"> hover-class="profile-menu__item--hover" hover-stay-time="150" @tap="handleTap(item)">
@@ -36,49 +24,24 @@ interface MenuItem {
title?: string title?: string
path?: string path?: string
isAdmin?: boolean isAdmin?: boolean
badge?: string action?: 'clear' | 'notifications'
action?: 'clear'
requireAuth?: boolean requireAuth?: boolean
} }
const props = defineProps<{ const props = defineProps<{
isAdmin: boolean isAdmin: boolean
requireAuth?: boolean requireAuth?: boolean
activeMembershipCount?: number
upcomingBookingCount?: number
inviteShareEligible?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'clear-cache'): void (e: 'clear-cache'): void
(e: 'require-login'): void (e: 'require-login'): void
(e: 'open-notifications'): void
}>() }>()
const menuItems = computed<MenuItem[]>(() => { const menuItems = computed<MenuItem[]>(() => {
const membershipBadge = props.activeMembershipCount && props.activeMembershipCount > 0
? `${props.activeMembershipCount}`
: undefined
const bookingBadge = props.upcomingBookingCount && props.upcomingBookingCount > 0
? `${props.upcomingBookingCount}`
: undefined
const items: MenuItem[] = [ const items: MenuItem[] = [
{ { key: 'progress', type: 'item', title: '我的成长档案', path: '/pages/profile/progress', requireAuth: true },
key: 'membership',
type: 'item',
title: '我的会员卡',
path: '/pages/profile/membership',
badge: membershipBadge,
requireAuth: true,
},
{
key: 'bookings',
type: 'item',
title: '我的预约',
path: '/pages/profile/bookings',
badge: bookingBadge,
requireAuth: true,
},
...(props.isAdmin ...(props.isAdmin
? [{ ? [{
key: 'teaching-schedule', key: 'teaching-schedule',
@@ -88,16 +51,13 @@ const menuItems = computed<MenuItem[]>(() => {
requireAuth: true, requireAuth: true,
}] }]
: []), : []),
// 临时隐藏邀请好友入口,后续恢复时直接取消这段注释即可。 {
// ...(props.inviteShareEligible key: 'bookings',
// ? [{ type: 'item',
// key: 'invite', title: '我的预约',
// type: 'item' as const, path: '/pages/profile/bookings',
// title: '邀请好友', requireAuth: true,
// path: '/pages/profile/invite', },
// requireAuth: true,
// }]
// : []),
{ {
key: 'info', key: 'info',
type: 'item', type: 'item',
@@ -105,6 +65,13 @@ const menuItems = computed<MenuItem[]>(() => {
path: '/pages/profile/info', path: '/pages/profile/info',
requireAuth: true, requireAuth: true,
}, },
{
key: 'notifications',
type: 'item',
title: '消息提醒设置',
action: 'notifications',
requireAuth: true,
},
{ {
key: 'sep1', key: 'sep1',
type: 'separator', type: 'separator',
@@ -132,9 +99,6 @@ const menuItems = computed<MenuItem[]>(() => {
return items return items
}) })
const primaryItems = computed(() => menuItems.value.filter(item => item.key === 'membership' || item.key === 'bookings'))
const secondaryItems = computed(() => menuItems.value.filter(item => item.key !== 'membership' && item.key !== 'bookings'))
function handleTap(item: MenuItem) { function handleTap(item: MenuItem) {
if (item.requireAuth && !props.requireAuth) { if (item.requireAuth && !props.requireAuth) {
emit('require-login') emit('require-login')
@@ -142,6 +106,8 @@ function handleTap(item: MenuItem) {
} }
if (item.action === 'clear') { if (item.action === 'clear') {
emit('clear-cache') emit('clear-cache')
} else if (item.action === 'notifications') {
emit('open-notifications')
} else if (item.path) { } else if (item.path) {
uni.navigateTo({ url: item.path }) uni.navigateTo({ url: item.path })
} }
@@ -150,14 +116,6 @@ function handleTap(item: MenuItem) {
<style lang="scss" scoped> <style lang="scss" scoped>
.profile-menu { .profile-menu {
&__shortcuts { display: flex; gap: 20rpx; margin: 24rpx 32rpx 0; }
&__shortcut { flex: 1; min-width: 0; padding: 24rpx; box-sizing: border-box; border-radius: 26rpx; background: #fff; border: 1rpx solid #eee8e3; }
&__shortcut-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16rpx; }
&__shortcut-title { display: block; font-size: 27rpx; font-weight: 500; color: #514943; }
&__shortcut-note { display: block; font-size: 22rpx; color: #8b817b; margin-top: 8rpx; }
&__icon { width: 36rpx; height: 30rpx; position: relative; box-sizing: border-box; border: 2rpx solid #9b8878; border-radius: 6rpx; }
&__icon--membership::after { content: ''; position: absolute; top: 8rpx; left: 0; right: 0; border-top: 2rpx solid #9b8878; }
&__icon--bookings { border-color: #829a8b; border-top-width: 7rpx; &::after { content: ''; position: absolute; left: 8rpx; top: 8rpx; width: 12rpx; border-top: 2rpx solid #829a8b; } }
&__links { margin: 24rpx 32rpx 0; border-radius: 26rpx; background: #fff; overflow: hidden; } &__links { margin: 24rpx 32rpx 0; border-radius: 26rpx; background: #fff; overflow: hidden; }
&__item { display: flex; align-items: center; gap: 20rpx; min-height: 96rpx; padding: 0 28rpx; box-sizing: border-box; border-bottom: 1rpx solid #f2ede8; &:last-child { border-bottom: none; } } &__item { display: flex; align-items: center; gap: 20rpx; min-height: 96rpx; padding: 0 28rpx; box-sizing: border-box; border-bottom: 1rpx solid #f2ede8; &:last-child { border-bottom: none; } }
&__item--hover { background: #f4f1ec; } &__item--hover { background: #f4f1ec; }

View File

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

View File

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

View File

@@ -49,36 +49,35 @@
</view> </view>
</view> </view>
<!-- Stats row: shown only when profile is loaded --> <button v-if="loggedIn && hasProfile" class="membership-row" @tap="handleMembershipTap">
<view v-if="loggedIn && hasProfile" class="user-card__stats"> <text class="membership-row__label">会员卡</text>
<view class="user-card__stat-item"> <text class="membership-row__value">{{ membershipLabel }}</text>
<text class="user-card__stat-value">{{ stats?.totalBookings ?? '—' }}</text> <text v-if="!membershipsError && membershipsLoaded && activeMemberships.length > 1" class="membership-row__count">{{ activeMemberships.length }} </text>
<text class="user-card__stat-label">累计上课 · </text> <text class="membership-row__arrow"></text>
</view> </button>
<view class="user-card__stat-divider" /> <view v-if="loggedIn && hasProfile && membershipsLoaded && !membershipsError && activeMemberships.length" class="mini-progress-list">
<view class="user-card__stat-item"> <button v-for="item in cardProgress" :key="item.id" class="mini-progress" :aria-label="`${item.name}${item.label}`" @tap="handleMembershipTap">
<text class="user-card__stat-value">{{ stats?.monthBookings ?? '—' }}</text> <view v-if="item.percent !== null" class="mini-progress__fill" :style="{ width: `${item.percent}%` }" />
<text class="user-card__stat-label">本月上课 · </text> <view class="mini-progress__heading"><text class="mini-progress__name">{{ item.name }}</text><text class="mini-progress__usage">{{ item.label }}</text></view>
</view> </button>
<view class="user-card__stat-divider" />
<view class="user-card__stat-item">
<text class="user-card__stat-value">{{ remainingSessions }}</text>
<text class="user-card__stat-label">剩余课时 · </text>
</view>
</view> </view>
</view> </view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import type { UserProfileResponse, UserStatsResponse, MembershipWithCardType } from '@mp-pilates/shared' import type { UserProfileResponse, MembershipWithCardType } from '@mp-pilates/shared'
import { MembershipStatus } from '@mp-pilates/shared' import { MembershipStatus } from '@mp-pilates/shared'
import { getCardTypeLabel, getMembershipTotalTimes, getMembershipUsedTimes } from '../utils/format'
const props = defineProps<{ const props = defineProps<{
loggedIn: boolean loggedIn: boolean
hasProfile: boolean hasProfile: boolean
user: UserProfileResponse | null user: UserProfileResponse | null
stats: UserStatsResponse | null now: number
membershipsLoading?: boolean
membershipsLoaded?: boolean
membershipsError?: boolean
memberships?: readonly MembershipWithCardType[] memberships?: readonly MembershipWithCardType[]
loading?: boolean loading?: boolean
}>() }>()
@@ -86,6 +85,7 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'login'): void (e: 'login'): void
(e: 'edit'): void (e: 'edit'): void
(e: 'refresh-memberships'): void
}>() }>()
const avatarFailed = ref(false) const avatarFailed = ref(false)
@@ -123,16 +123,32 @@ const activeMembershipCount = computed(
const hasMembership = computed(() => activeMembershipCount.value > 0) const hasMembership = computed(() => activeMembershipCount.value > 0)
function toSafeCount(value: number | null | undefined): number { const membershipLabel = computed(() => {
return typeof value === 'number' && Number.isFinite(value) ? value : 0 if (props.membershipsError) return '暂未更新,点击重试'
if (!props.membershipsLoaded) return '加载中…'
if (!activeMemberships.value.length) return '暂无有效卡'
return [...new Set(activeMemberships.value.map(m => getCardTypeLabel(m.cardType.type)))].join(' · ')
})
const cardProgress = computed(() => activeMemberships.value.map(m => {
const total = getMembershipTotalTimes(m)
if (m.remainingTimes !== null) {
const used = getMembershipUsedTimes(m)
return { id: m.id, name: m.cardType.name,
label: total && total > 0 ? `已用 ${used}/${total} 次 · 余 ${m.remainingTimes}` : `剩余 ${m.remainingTimes}`,
percent: total && total > 0 ? Math.max(0, Math.min(100, used / total * 100)) : null }
} }
const start = new Date(m.startDate).getTime()
const end = new Date(m.expireDate).getTime()
const remainingDays = Math.max(0, Math.ceil((end - props.now) / 86400000))
return { id: m.id, name: m.cardType.name,
label: `不限次 · 有效期剩 ${remainingDays}`,
percent: end > start ? Math.max(0, Math.min(100, (end - props.now) / (end - start) * 100)) : null }
}))
// Sum remaining sessions from all active count-limited memberships. function handleMembershipTap() {
const remainingSessions = computed(() => if (props.membershipsError) { emit('refresh-memberships'); return }
activeMemberships.value uni.navigateTo({ url: '/pages/profile/membership' })
.filter((m) => m.remainingTimes !== null) }
.reduce((sum, m) => sum + toSafeCount(m.remainingTimes), 0),
)
function onAvatarError() { function onAvatarError() {
avatarFailed.value = true avatarFailed.value = true
@@ -155,11 +171,6 @@ function handleLogin() {
&__member-label { flex-shrink: 0; font-size: 19rpx; color: #8b6c5b; background: #fbf5ef; padding: 4rpx 12rpx; border-radius: 999rpx; } &__member-label { flex-shrink: 0; font-size: 19rpx; color: #8b6c5b; background: #fbf5ef; padding: 4rpx 12rpx; border-radius: 999rpx; }
&__phone { font-size: 24rpx; color: #8b7b70; } &__phone { font-size: 24rpx; color: #8b7b70; }
&__edit { flex-shrink: 0; font-size: 22rpx; color: #8b7b70; padding: 16rpx 0 16rpx 8rpx; } &__edit { flex-shrink: 0; font-size: 22rpx; color: #8b7b70; padding: 16rpx 0 16rpx 8rpx; }
&__stats { display: flex; align-items: stretch; margin-top: 28rpx; padding-top: 26rpx; border-top: 1rpx solid #e3d7ce; }
&__stat-item { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 10rpx; }
&__stat-value { font-size: 40rpx; font-weight: 400; color: #6f5c50; line-height: 1.15; font-variant-numeric: tabular-nums; }
&__stat-label { font-size: 20rpx; color: #8b7b70; }
&__stat-divider { width: 1rpx; margin: 6rpx 0; background: #e3d7ce; }
&__guest { display: flex; align-items: center; flex-wrap: wrap; gap: 20rpx; } &__guest { display: flex; align-items: center; flex-wrap: wrap; gap: 20rpx; }
&__guest-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10rpx; } &__guest-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10rpx; }
&__guest-title { font-size: 32rpx; font-weight: 500; color: #514943; } &__guest-title { font-size: 32rpx; font-weight: 500; color: #514943; }
@@ -169,4 +180,15 @@ function handleLogin() {
&__nickname-skeleton { width: 150rpx; height: 32rpx; border-radius: 8rpx; background: #e6d9ce; } &__nickname-skeleton { width: 150rpx; height: 32rpx; border-radius: 8rpx; background: #e6d9ce; }
&__phone-skeleton { width: 180rpx; height: 22rpx; border-radius: 6rpx; background: #e6d9ce; } &__phone-skeleton { width: 180rpx; height: 22rpx; border-radius: 6rpx; background: #e6d9ce; }
} }
.membership-row { display: flex; align-items: center; gap: 12rpx; width: 100%; margin: 20rpx 0 0; padding: 18rpx 0 0; min-height: 62rpx; border-radius: 0; border-top: 1rpx solid #e3d7ce; background: transparent; text-align: left; line-height: 1.5; font-size: 23rpx; color: #796759; &::after { border: none; } }
.membership-row__label { flex-shrink: 0; color: #8b7b70; }
.membership-row__value { flex: 1; min-width: 0; text-align: right; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.membership-row__count { flex-shrink: 0; font-size: 21rpx; color: #8b7b70; }
.membership-row__arrow { flex-shrink: 0; font-size: 28rpx; color: #8b7b70; }
.mini-progress-list { margin-top: 12rpx; display: flex; flex-direction: column; gap: 8rpx; }
.mini-progress { position: relative; display: block; width: 100%; height: 48rpx; margin: 0; padding: 0 16rpx; overflow: hidden; border: 1rpx solid #d6d9ca; border-radius: 10rpx; background: #eeece4; text-align: left; line-height: 46rpx; &::after { border: none; } &:active { opacity: .8; } }
.mini-progress__heading { position: relative; z-index: 1; display: flex; align-items: center; gap: 12rpx; height: 100%; font-size: 20rpx; color: #46513f; }
.mini-progress__name { flex: 1; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.mini-progress__usage { flex-shrink: 0; font-variant-numeric: tabular-nums; }
.mini-progress__fill { position: absolute; inset: 0 auto 0 0; background: linear-gradient(90deg, #dce3d3, #c9d7bf); border-right: 1rpx solid rgba(109, 135, 91, .16); }
</style> </style>

View File

@@ -13,6 +13,10 @@
"minified": true "minified": true
}, },
"usingComponents": true, "usingComponents": true,
"lazyCodeLoading": "requiredComponents",
"optimization": {
"subPackages": true
},
"permission": { "permission": {
"scope.userLocation": { "scope.userLocation": {
"desc": "用于获取工作室位置导航" "desc": "用于获取工作室位置导航"

View File

@@ -12,13 +12,19 @@
{ {
"path": "pages/booking/index", "path": "pages/booking/index",
"style": { "style": {
"navigationStyle": "custom" "navigationStyle": "custom",
"componentPlaceholder": {
"booking-confirm-popup": "view"
}
} }
}, },
{ {
"path": "pages/booking/detail", "path": "pages/booking/detail",
"style": { "style": {
"navigationStyle": "custom" "navigationStyle": "custom",
"componentPlaceholder": {
"booking-confirm-popup": "view"
}
} }
}, },
{ {
@@ -70,86 +76,108 @@
} }
}, },
{ {
"path": "pages/admin/index", "path": "pages/profile/progress",
"style": {
"navigationStyle": "custom"
}
}
],
"subPackages": [
{
"root": "pages/admin",
"pages": [
{
"path": "index",
"style": { "style": {
"navigationStyle": "custom" "navigationStyle": "custom"
} }
}, },
{ {
"path": "pages/admin/bookings", "path": "analytics",
"style": {
"navigationStyle": "custom",
"enablePullDownRefresh": true
}
},
{
"path": "bookings",
"style": { "style": {
"navigationStyle": "custom" "navigationStyle": "custom"
} }
}, },
{ {
"path": "pages/admin/schedule", "path": "schedule",
"style": { "style": {
"navigationStyle": "custom" "navigationStyle": "custom"
} }
}, },
{ {
"path": "pages/admin/slot-adjust", "path": "slot-adjust",
"style": { "style": {
"navigationStyle": "custom" "navigationStyle": "custom"
} }
}, },
{ {
"path": "pages/admin/members", "path": "members",
"style": { "style": {
"navigationStyle": "custom" "navigationStyle": "custom"
} }
}, },
{ {
"path": "pages/admin/member-detail", "path": "member-detail",
"style": { "style": {
"navigationStyle": "custom" "navigationStyle": "custom"
} }
}, },
{ {
"path": "pages/admin/member-edit", "path": "member-edit",
"style": { "style": {
"navigationStyle": "custom" "navigationStyle": "custom"
} }
}, },
{ {
"path": "pages/admin/member-supplement", "path": "member-supplement",
"style": { "navigationStyle": "custom" }
},
{
"path": "pages/admin/member-arrange",
"style": { "style": {
"navigationStyle": "custom" "navigationStyle": "custom"
} }
}, },
{ {
"path": "pages/admin/orders", "path": "member-arrange",
"style": { "style": {
"navigationStyle": "custom" "navigationStyle": "custom"
} }
}, },
{ {
"path": "pages/admin/card-types", "path": "orders",
"style": { "style": {
"navigationStyle": "custom" "navigationStyle": "custom"
} }
}, },
{ {
"path": "pages/admin/studio", "path": "card-types",
"style": { "style": {
"navigationStyle": "custom" "navigationStyle": "custom"
} }
}, },
{ {
"path": "pages/admin/flash-sales", "path": "studio",
"style": { "style": {
"navigationStyle": "custom" "navigationStyle": "custom"
} }
}, },
{ {
"path": "pages/flash-sale/detail", "path": "member-progress",
"style": { "style": {
"navigationStyle": "custom" "navigationStyle": "custom"
} }
},
{
"path": "reviews",
"style": {
"navigationStyle": "custom"
}
}
]
} }
], ],
"globalStyle": { "globalStyle": {

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -239,9 +239,9 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import CustomNavBar from '../../components/CustomNavBar.vue' import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system' import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from '../../stores/admin' import { useAdminStore } from './stores/admin'
import { formatPrice } from '../../utils/format' import { formatPrice } from '../../utils/format'
import { uploadStudioAsset } from '../../utils/studio-upload' import { uploadStudioAsset } from './utils/studio-upload'
import { CardTypeCategory } from '@mp-pilates/shared' import { CardTypeCategory } from '@mp-pilates/shared'
import type { CardType } from '@mp-pilates/shared' import type { CardType } from '@mp-pilates/shared'

View File

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

View File

@@ -2,174 +2,70 @@
<view class="page" :style="{ paddingTop: navBarHeight }"> <view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="管理中心" show-back /> <CustomNavBar title="管理中心" show-back />
<!-- Stats summary card --> <!-- Section: 课务运营 -->
<view class="stats-card-wrap">
<view class="stats-card">
<view v-if="statsLoading" class="stats-loading">
<view v-for="i in 3" :key="i" class="stat-skeleton" />
</view>
<template v-else>
<view class="stat-block">
<text class="stat-num">{{ stats.todayBookings }}</text>
<text class="stat-sub">今日预约</text>
</view>
<view class="stat-sep" />
<view class="stat-block">
<text class="stat-num">{{ stats.totalOrders }}</text>
<text class="stat-sub">总订单</text>
</view>
<view class="stat-sep" />
<view class="stat-block">
<text class="stat-num">{{ stats.totalBookings }}</text>
<text class="stat-sub">总预约</text>
</view>
</template>
</view>
</view>
<!-- Section header: 课程管理 -->
<view class="section-header"> <view class="section-header">
<text class="section-title">程管理</text> <text class="section-title">务运营</text>
</view> </view>
<!-- List: schedule -->
<view class="list"> <view class="list">
<view class="list-item" @tap="navigate('/pages/admin/bookings')">
<view class="item-left">
<view class="item-icon-wrap icon--bookings">
<text class="item-icon-text"></text>
</view>
<view class="item-text-group">
<text class="item-title">预约管理</text>
<text class="item-desc">查看/确认/核销学员预约</text>
</view>
</view>
<view class="item-arrow">
<text class="arrow-text"></text>
</view>
</view>
<view class="list-item" @tap="navigate('/pages/admin/schedule')"> <view class="list-item" @tap="navigate('/pages/admin/schedule')">
<view class="item-left">
<view class="item-icon-wrap icon--schedule">
<text class="item-icon-text"></text>
</view>
<view class="item-text-group">
<text class="item-title">排课管理</text> <text class="item-title">排课管理</text>
<text class="item-desc">管理每周课程时段</text> <text class="arrow-text"></text>
</view> </view>
</view> <view class="list-item" @tap="navigate('/pages/admin/bookings')">
<view class="item-arrow"> <text class="item-title">预约管理</text>
<text class="arrow-text"></text> <text class="arrow-text"></text>
</view> </view>
</view> </view>
<!-- Section: 教学复盘 -->
<view class="section-header">
<text class="section-title">教学复盘</text>
</view>
<view class="list">
<view class="list-item" @tap="navigate('/pages/admin/analytics')">
<text class="item-title">统计分析</text>
<text class="arrow-text"></text>
</view>
<view class="list-item" @tap="navigate('/pages/admin/reviews')">
<text class="item-title">课后评价</text>
<text class="arrow-text"></text>
</view>
</view> </view>
<!-- Section header: 会员与订单 --> <!-- Section: 会员与订单 -->
<view class="section-header"> <view class="section-header">
<text class="section-title">会员与订单</text> <text class="section-title">会员与订单</text>
</view> </view>
<!-- List: members & orders -->
<view class="list"> <view class="list">
<view class="list-item" @tap="navigate('/pages/admin/members')"> <view class="list-item" @tap="navigate('/pages/admin/members')">
<view class="item-left">
<view class="item-icon-wrap icon--members">
<text class="item-icon-text"></text>
</view>
<view class="item-text-group">
<text class="item-title">会员管理</text> <text class="item-title">会员管理</text>
<text class="item-desc">查看所有会员信息</text>
</view>
</view>
<view class="item-arrow">
<text class="arrow-text"></text> <text class="arrow-text"></text>
</view> </view>
</view>
<view class="list-item" @tap="navigate('/pages/admin/orders')"> <view class="list-item" @tap="navigate('/pages/admin/orders')">
<view class="item-left">
<view class="item-icon-wrap icon--orders">
<text class="item-icon-text"></text>
</view>
<view class="item-text-group">
<text class="item-title">订单管理</text> <text class="item-title">订单管理</text>
<text class="item-desc">查看所有订单记录</text>
</view>
</view>
<view class="item-arrow">
<text class="arrow-text"></text> <text class="arrow-text"></text>
</view> </view>
</view> </view>
<view class="list-item" @tap="navigate('/pages/admin/card-types')"> <!-- Section: 系统设置 -->
<view class="item-left">
<view class="item-icon-wrap icon--card">
<text class="item-icon-text"></text>
</view>
<view class="item-text-group">
<text class="item-title">卡种管理</text>
<text class="item-desc">设置会员卡类型</text>
</view>
</view>
<view class="item-arrow">
<text class="arrow-text"></text>
</view>
</view>
<view class="list-item" @tap="navigate('/pages/admin/flash-sales')">
<view class="item-left">
<view class="item-icon-wrap icon--flash-sale">
<text class="item-icon-text"></text>
</view>
<view class="item-text-group">
<text class="item-title">秒杀管理</text>
<text class="item-desc">创建和管理限时秒杀活动</text>
</view>
</view>
<view class="item-arrow">
<text class="arrow-text"></text>
</view>
</view>
</view>
<!-- Section header: 系统 -->
<view class="section-header"> <view class="section-header">
<text class="section-title">系统</text> <text class="section-title">系统设置</text>
</view> </view>
<!-- List: settings -->
<view class="list"> <view class="list">
<view class="list-item" @tap="navigate('/pages/admin/studio')"> <view class="list-item" @tap="navigate('/pages/admin/card-types')">
<view class="item-left"> <text class="item-title">卡种管理</text>
<view class="item-icon-wrap icon--studio">
<text class="item-icon-text"></text>
</view>
<view class="item-text-group">
<text class="item-title">工作室设置</text>
<text class="item-desc">工作室信息与配置</text>
</view>
</view>
<view class="item-arrow">
<text class="arrow-text"></text> <text class="arrow-text"></text>
</view> </view>
<view class="list-item" @tap="navigate('/pages/admin/studio')">
<text class="item-title">工作室设置</text>
<text class="arrow-text"></text>
</view> </view>
<view class="list-item" @tap="handleIncreaseSubscriptionCount"> <view class="list-item" @tap="handleIncreaseSubscriptionCount">
<view class="item-left">
<view class="item-icon-wrap icon--subscribe">
<text class="item-icon-text"></text>
</view>
<view class="item-text-group">
<text class="item-title">增加订阅次数</text> <text class="item-title">增加订阅次数</text>
<text class="item-desc">当前剩余 {{ user?.adminBookingSubscriptionCount ?? 0 }} </text> <text class="item-extra">剩余 {{ user?.adminBookingSubscriptionCount ?? 0 }} </text>
</view>
</view>
<view class="item-arrow">
<text class="arrow-text">{{ adminSubscribeLoading ? '...' : '' }}</text> <text class="arrow-text">{{ adminSubscribeLoading ? '...' : '' }}</text>
</view> </view>
</view> </view>
</view>
<view style="height: 40rpx" /> <view style="height: 40rpx" />
</view> </view>
@@ -180,37 +76,21 @@ import { ref, onMounted } from 'vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import CustomNavBar from '../../components/CustomNavBar.vue' import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system' import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from '../../stores/admin'
import { useUserStore } from '../../stores/user' import { useUserStore } from '../../stores/user'
import type { AdminStats } from '../../stores/admin'
import { requestAdminBookingSubscriptionCount } from '../../utils/wechat-subscription' import { requestAdminBookingSubscriptionCount } from '../../utils/wechat-subscription'
import { getErrorMessage } from '../../utils/auth' import { getErrorMessage } from '../../utils/auth'
const navBarHeight = ref('64px') const navBarHeight = ref('64px')
const adminStore = useAdminStore()
const userStore = useUserStore() const userStore = useUserStore()
const { user } = storeToRefs(userStore) const { user } = storeToRefs(userStore)
const statsLoading = ref(false)
const stats = ref<AdminStats>({ todayBookings: 0, totalOrders: 0, totalBookings: 0 })
const adminSubscribeLoading = ref(false) const adminSubscribeLoading = ref(false)
function navigate(path: string) { function navigate(path: string) {
uni.navigateTo({ url: path }) uni.navigateTo({ url: path })
} }
async function loadStats() {
statsLoading.value = true
try {
stats.value = await adminStore.fetchDashboardStats()
} catch {
// fail silently — stats are non-critical
} finally {
statsLoading.value = false
}
}
async function handleIncreaseSubscriptionCount() { async function handleIncreaseSubscriptionCount() {
if (adminSubscribeLoading.value) { if (adminSubscribeLoading.value) {
return return
@@ -235,78 +115,18 @@ async function handleIncreaseSubscriptionCount() {
onMounted(() => { onMounted(() => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px` navBarHeight.value = `${getSystemLayout().navBarHeight}px`
loadStats()
userStore.fetchProfile() userStore.fetchProfile()
}) })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
/* ── Page ───────────────────────────────────── */ /* ── Page ───────────────────────── */
.page { .page {
min-height: 100vh; min-height: 100vh;
padding-bottom: 40rpx; padding-bottom: 40rpx;
} }
/* ── Stats card ─────────────────────────────── */ /* ── Section header ───────────────── */
.stats-card-wrap {
padding: 24rpx 24rpx 8rpx;
}
.stats-card {
background: #FFFFFF;
border-radius: 20rpx;
padding: 32rpx 24rpx;
display: flex;
align-items: center;
box-shadow: 0 4rpx 20rpx rgba(180, 160, 130, 0.10);
border: 1rpx solid rgba(180, 160, 130, 0.12);
}
.stats-loading {
width: 100%;
display: flex;
align-items: center;
justify-content: space-around;
}
.stat-skeleton {
width: 100rpx;
height: 64rpx;
border-radius: 12rpx;
background: linear-gradient(90deg, $primary-border 25%, $primary-light 50%, $primary-border 75%);
background-size: 400% 100%;
animation: shimmer 1.6s ease infinite;
}
.stat-block {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
}
.stat-num {
font-size: 44rpx;
font-weight: 700;
color: #4A4035;
line-height: 1;
font-family: 'DIN Alternate', 'Helvetica Neue', Arial, sans-serif;
}
.stat-sub {
font-size: 22rpx;
color: #A09080;
letter-spacing: 0.5rpx;
}
.stat-sep {
width: 1rpx;
height: 56rpx;
background: rgba(180, 160, 130, 0.2);
}
/* ── Section header ─────────────────────────── */
.section-header { .section-header {
padding: 32rpx 24rpx 12rpx; padding: 32rpx 24rpx 12rpx;
} }
@@ -319,7 +139,7 @@ onMounted(() => {
text-transform: uppercase; text-transform: uppercase;
} }
/* ── List ───────────────────────────────────── */ /* ── List ───────────────────────── */
.list { .list {
background: #FFFFFF; background: #FFFFFF;
margin: 0 24rpx; margin: 0 24rpx;
@@ -332,8 +152,8 @@ onMounted(() => {
.list-item { .list-item {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; gap: 16rpx;
padding: 28rpx 24rpx; padding: 32rpx 28rpx;
border-bottom: 1rpx solid rgba(180, 160, 130, 0.1); border-bottom: 1rpx solid rgba(180, 160, 130, 0.1);
transition: background 0.15s ease; transition: background 0.15s ease;
@@ -346,64 +166,23 @@ onMounted(() => {
} }
} }
.item-left {
display: flex;
align-items: center;
gap: 20rpx;
}
.item-icon-wrap {
width: 72rpx;
height: 72rpx;
border-radius: 18rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.item-icon-text {
font-size: 32rpx;
color: #FFFFFF;
line-height: 1;
}
/* Icon variants — warm muted tones */
.icon--bookings { background: linear-gradient(135deg, #C4A87E, #B49868); }
.icon--schedule { background: linear-gradient(135deg, #8B9E7E, #7A8E6E); }
.icon--template { background: linear-gradient(135deg, #A090C0, #9080B0); }
.icon--members { background: linear-gradient(135deg, $primary-color, $primary-dark); }
.icon--orders { background: linear-gradient(135deg, #7E9EC4, #6E8EB4); }
.icon--card { background: linear-gradient(135deg, #C48E7E, #B47E6E); }
.icon--flash-sale { background: linear-gradient(135deg, #D4A59A, #C08B7E); }
.icon--studio { background: linear-gradient(135deg, #9E9E7E, #8E8E6E); }
.icon--subscribe { background: linear-gradient(135deg, #5D8C8A, #476D72); }
.item-text-group {
display: flex;
flex-direction: column;
gap: 4rpx;
}
.item-title { .item-title {
flex: 1;
font-size: 30rpx; font-size: 30rpx;
font-weight: 600; font-weight: 500;
color: #4A4035; color: #4A4035;
letter-spacing: 0.5rpx; letter-spacing: 0.5rpx;
} }
.item-desc { .item-extra {
font-size: 24rpx; font-size: 24rpx;
color: #A09080; color: #A09080;
}
.item-arrow {
flex-shrink: 0; flex-shrink: 0;
padding-left: 16rpx;
} }
.arrow-text { .arrow-text {
font-size: 40rpx; flex-shrink: 0;
font-size: 36rpx;
color: rgba(180, 160, 130, 0.5); color: rgba(180, 160, 130, 0.5);
font-weight: 300; font-weight: 300;
line-height: 1; line-height: 1;

View File

@@ -115,7 +115,7 @@ import TimePeriodFilter from '../../components/TimePeriodFilter.vue'
import { getSystemLayout } from '../../utils/system' import { getSystemLayout } from '../../utils/system'
import { formatDate, isSlotPast } from '../../utils/format' import { formatDate, isSlotPast } from '../../utils/format'
import { getErrorMessage } from '../../utils/auth' import { getErrorMessage } from '../../utils/auth'
import { useAdminStore } from '../../stores/admin' import { useAdminStore } from './stores/admin'
type PeriodKey = keyof typeof TIME_PERIODS | null type PeriodKey = keyof typeof TIME_PERIODS | null

View File

@@ -40,6 +40,9 @@
</view> </view>
</view> </view>
<view class="member-tabs"><button :class="{ selected: activeTab === 'overview' }" @tap="activeTab = 'overview'">会员概览</button><button :class="{ selected: activeTab === 'progress' }" @tap="activeTab = 'progress'">成长档案</button><button @tap="goReviews">课后评价 </button></view>
<MemberProgress v-if="activeTab === 'progress'" admin :user-id="userId" :refresh-key="progressRefreshKey" />
<template v-if="activeTab === 'overview'">
<view class="section section--practice"> <view class="section section--practice">
<view class="section-heading"> <view class="section-heading">
<text class="section-label">上课情况</text> <text class="section-label">上课情况</text>
@@ -138,8 +141,9 @@
</view> </view>
</view> </view>
</template> </template>
</template>
<view class="dock"> <view v-if="activeTab === 'overview'" class="dock">
<view class="dock-btn dock-btn--ghost" @tap="goEdit"> <view class="dock-btn dock-btn--ghost" @tap="goEdit">
<text class="dock-btn-text">编辑资料</text> <text class="dock-btn-text">编辑资料</text>
</view> </view>
@@ -159,6 +163,7 @@ import { ref, computed, onMounted } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app' import { onLoad, onShow } from '@dcloudio/uni-app'
import type { AdminMemberDetail, MembershipWithCardType } from '@mp-pilates/shared' import type { AdminMemberDetail, MembershipWithCardType } from '@mp-pilates/shared'
import { MembershipStatus, BookingStatus } from '@mp-pilates/shared' import { MembershipStatus, BookingStatus } from '@mp-pilates/shared'
import MemberProgress from '../../components/MemberProgress.vue'
import CustomNavBar from '../../components/CustomNavBar.vue' import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system' import { getSystemLayout } from '../../utils/system'
import { import {
@@ -170,12 +175,13 @@ import {
} from '../../utils/format' } from '../../utils/format'
import { BOOKING_STATUS_LABELS } from '../../utils/booking-helpers' import { BOOKING_STATUS_LABELS } from '../../utils/booking-helpers'
import { getErrorMessage } from '../../utils/auth' import { getErrorMessage } from '../../utils/auth'
import { useAdminStore } from '../../stores/admin' import { useAdminStore } from './stores/admin'
const adminStore = useAdminStore() const adminStore = useAdminStore()
const navBarHeight = ref('64px') const navBarHeight = ref('64px')
const userId = ref('') const userId = ref('')
const loading = ref(false) const loading = ref(false)
const activeTab = ref('overview'), progressRefreshKey = ref(0)
const detail = ref<AdminMemberDetail | null>(null) const detail = ref<AdminMemberDetail | null>(null)
const canArrange = computed(() => (detail.value?.memberships ?? []).some(isArrangableMembership)) const canArrange = computed(() => (detail.value?.memberships ?? []).some(isArrangableMembership))
@@ -224,6 +230,7 @@ async function loadDetail() {
} }
} }
function goReviews() { uni.navigateTo({ url: `/pages/admin/reviews?userId=${userId.value}` }) }
function goSupplement() { function goSupplement() {
if (userId.value) uni.navigateTo({ url: `/pages/admin/member-supplement?userId=${userId.value}` }) if (userId.value) uni.navigateTo({ url: `/pages/admin/member-supplement?userId=${userId.value}` })
} }
@@ -250,6 +257,7 @@ onMounted(() => {
}) })
onShow(() => { onShow(() => {
progressRefreshKey.value++
if (userId.value) { if (userId.value) {
loadDetail() loadDetail()
} }
@@ -257,6 +265,9 @@ onShow(() => {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.member-tabs {display:flex;margin:28rpx 32rpx 0;border-bottom:1rpx solid #e5dfd6;gap:8rpx;}
.member-tabs button{flex:1;margin:0;padding:0;background:transparent;border-radius:0;line-height:88rpx;font-size:25rpx;color:#8b817b;border-bottom:4rpx solid transparent;&::after{border:0;}&.selected{border-color:#617d73;color:#617d73;}}
.page { .page {
--ink: #514943; --ink: #514943;
--muted: #8b817b; --muted: #8b817b;

View File

@@ -111,7 +111,7 @@ import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system' import { getSystemLayout } from '../../utils/system'
import { formatDateLocal } from '../../utils/format' import { formatDateLocal } from '../../utils/format'
import { getErrorMessage } from '../../utils/auth' import { getErrorMessage } from '../../utils/auth'
import { useAdminStore } from '../../stores/admin' import { useAdminStore } from './stores/admin'
const adminStore = useAdminStore() const adminStore = useAdminStore()
const navBarHeight = ref('64px') const navBarHeight = ref('64px')

View File

@@ -0,0 +1,12 @@
<template><view class="page" :style="{ paddingTop: navBarHeight + 'px' }"><CustomNavBar title="成长档案" show-back /><MemberProgress admin :user-id="userId" :booking-id="bookingId" /></view></template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import MemberProgress from '../../components/MemberProgress.vue'
import { getSystemLayout } from '../../utils/system'
const navBarHeight = getSystemLayout().navBarHeight
const userId = ref(''), bookingId = ref('')
onLoad(query => { userId.value = String(query?.userId || ''); bookingId.value = String(query?.bookingId || '') })
</script>
<style scoped>.page{min-height:100vh;background:#fbf9f6;box-sizing:border-box;}</style>

View File

@@ -60,7 +60,7 @@ import { onLoad } from '@dcloudio/uni-app'
import type { AdminMemberDetail, CreateLessonSupplementDto, LessonSupplementRecord } from '@mp-pilates/shared' import type { AdminMemberDetail, CreateLessonSupplementDto, LessonSupplementRecord } from '@mp-pilates/shared'
import CustomNavBar from '../../components/CustomNavBar.vue' import CustomNavBar from '../../components/CustomNavBar.vue'
import LessonSupplementList from '../../components/LessonSupplementList.vue' import LessonSupplementList from '../../components/LessonSupplementList.vue'
import { useAdminStore } from '../../stores/admin' import { useAdminStore } from './stores/admin'
import { getSystemLayout } from '../../utils/system' import { getSystemLayout } from '../../utils/system'
import { HttpRequestError } from '../../utils/request' import { HttpRequestError } from '../../utils/request'
import { getErrorMessage } from '../../utils/auth' import { getErrorMessage } from '../../utils/auth'

View File

@@ -3,6 +3,7 @@
<CustomNavBar title="会员管理" show-back /> <CustomNavBar title="会员管理" show-back />
<view class="filter-bar"> <view class="filter-bar">
<view class="search-field">
<input <input
class="search-input" class="search-input"
v-model="searchQuery" v-model="searchQuery"
@@ -14,6 +15,7 @@
<view v-if="searchQuery" class="search-clear" @tap="onClear"> <view v-if="searchQuery" class="search-clear" @tap="onClear">
<text class="search-clear-icon">×</text> <text class="search-clear-icon">×</text>
</view> </view>
</view>
<picker <picker
class="type-picker" class="type-picker"
mode="selector" mode="selector"
@@ -35,7 +37,7 @@
<view class="stats-row"> <view class="stats-row">
<view class="stat-item"> <view class="stat-item">
<text class="stat-value">{{ total }}</text> <text class="stat-value">{{ total }}</text>
<text class="stat-label">会员</text> <text class="stat-label">用户</text>
</view> </view>
</view> </view>
@@ -47,7 +49,7 @@
<view class="empty-icon-wrap"> <view class="empty-icon-wrap">
<view class="empty-icon-person" /> <view class="empty-icon-person" />
</view> </view>
<text class="empty-text">{{ searchQuery ? '未找到匹配的会员' : '暂无会员数据' }}</text> <text class="empty-text">{{ searchQuery ? '未找到匹配的用户' : '当前筛选下暂无用户' }}</text>
</view> </view>
<view v-else class="member-list"> <view v-else class="member-list">
@@ -101,8 +103,8 @@ import { onReachBottom, onShow } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue' import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system' import { getSystemLayout } from '../../utils/system'
import { getCardTypeLabel } from '../../utils/format' import { getCardTypeLabel } from '../../utils/format'
import { useAdminStore } from '../../stores/admin' import { useAdminStore } from './stores/admin'
import type { MemberSummary } from '../../stores/admin' import type { MemberSummary } from './stores/admin'
const adminStore = useAdminStore() const adminStore = useAdminStore()
@@ -116,17 +118,25 @@ const hasMore = ref(false)
const LIMIT = 20 const LIMIT = 20
const cardTypeOptions = [ const cardTypeOptions = [
{ label: '全部', value: '' }, { label: '会员用户', value: 'ACTIVE' },
{ label: '全部用户', value: '' },
{ label: '体验卡', value: 'TRIAL' }, { label: '体验卡', value: 'TRIAL' },
{ label: '次卡', value: 'TIMES' }, { label: '次卡', value: 'TIMES' },
{ label: '月卡', value: 'DURATION' }, { label: '月卡', value: 'DURATION' },
{ label: '无卡', value: 'NONE' }, { label: '无卡用户', value: 'NONE' },
] ]
const cardTypeIndex = ref(0) const cardTypeIndex = ref(0)
let requestId = 0
let cardTypeDebounceTimer: ReturnType<typeof setTimeout> | null = null let cardTypeDebounceTimer: ReturnType<typeof setTimeout> | null = null
function onCardTypeChange(e: { detail: { value: number } }) { function onCardTypeChange(e: { detail: { value: number } }) {
cardTypeIndex.value = Number(e.detail.value) cardTypeIndex.value = Number(e.detail.value)
// Invalidate the old filter immediately, including during the debounce window.
requestId++
loading.value = true
members.value = []
total.value = 0
hasMore.value = false
if (cardTypeDebounceTimer) clearTimeout(cardTypeDebounceTimer) if (cardTypeDebounceTimer) clearTimeout(cardTypeDebounceTimer)
cardTypeDebounceTimer = setTimeout(() => { cardTypeDebounceTimer = setTimeout(() => {
loadMembers(true) loadMembers(true)
@@ -135,25 +145,32 @@ function onCardTypeChange(e: { detail: { value: number } }) {
} }
onUnmounted(() => { onUnmounted(() => {
requestId++
if (cardTypeDebounceTimer) clearTimeout(cardTypeDebounceTimer) if (cardTypeDebounceTimer) clearTimeout(cardTypeDebounceTimer)
}) })
async function loadMembers(reset = false) { async function loadMembers(reset = false) {
if (loading.value) return if (loading.value && !reset) return
const id = ++requestId
const requestedPage = reset ? 1 : page.value + 1
if (reset) { if (reset) {
page.value = 1 page.value = 1
members.value = [] members.value = []
total.value = 0
hasMore.value = false
} }
loading.value = true loading.value = true
try { try {
const search = searchQuery.value.trim() const search = searchQuery.value.trim()
const cardType = cardTypeOptions[cardTypeIndex.value].value const cardType = cardTypeOptions[cardTypeIndex.value].value
const result = await adminStore.fetchMembers({ const result = await adminStore.fetchMembers({
page: page.value, page: requestedPage,
limit: LIMIT, limit: LIMIT,
...(search ? { search } : {}), ...(search ? { search } : {}),
...(cardType ? { cardType } : {}), ...(cardType ? { cardType } : {}),
}) })
if (id !== requestId) return
page.value = requestedPage
if (reset) { if (reset) {
members.value = [...result.items] members.value = [...result.items]
} else { } else {
@@ -162,14 +179,15 @@ async function loadMembers(reset = false) {
total.value = result.total total.value = result.total
hasMore.value = members.value.length < result.total hasMore.value = members.value.length < result.total
} catch { } catch {
uni.showToast({ title: '加载失败', icon: 'none' }) if (id === requestId) uni.showToast({ title: '加载失败', icon: 'none' })
} finally { } finally {
loading.value = false if (id === requestId) loading.value = false
} }
} }
async function refreshVisibleMembers() { async function refreshVisibleMembers() {
if (loading.value) return if (loading.value) return
const id = ++requestId
loading.value = true loading.value = true
try { try {
const search = searchQuery.value.trim() const search = searchQuery.value.trim()
@@ -181,14 +199,15 @@ async function refreshVisibleMembers() {
...(search ? { search } : {}), ...(search ? { search } : {}),
...(cardType ? { cardType } : {}), ...(cardType ? { cardType } : {}),
}) })
if (id !== requestId) return
members.value = [...result.items] members.value = [...result.items]
total.value = result.total total.value = result.total
page.value = Math.max(1, Math.ceil(members.value.length / LIMIT) || 1) page.value = Math.max(1, Math.ceil(members.value.length / LIMIT) || 1)
hasMore.value = members.value.length < result.total hasMore.value = members.value.length < result.total
} catch { } catch {
uni.showToast({ title: '加载失败', icon: 'none' }) if (id === requestId) uni.showToast({ title: '加载失败', icon: 'none' })
} finally { } finally {
loading.value = false if (id === requestId) loading.value = false
} }
} }
@@ -203,7 +222,6 @@ function onClear() {
onReachBottom(() => { onReachBottom(() => {
if (!hasMore.value || loading.value) return if (!hasMore.value || loading.value) return
page.value++
loadMembers(false) loadMembers(false)
}) })
@@ -244,19 +262,27 @@ onShow(() => {
position: relative; position: relative;
} }
.search-field {
flex: 1;
min-width: 0;
position: relative;
}
.search-input { .search-input {
flex: 1; flex: 1;
min-width: 0;
height: 72rpx; height: 72rpx;
background: $bg-page; background: $bg-page;
border-radius: 36rpx; border-radius: 36rpx;
padding: 0 28rpx; padding: 0 60rpx 0 24rpx;
font-size: 26rpx; font-size: 26rpx;
color: $text-primary; color: $text-primary;
} }
.search-clear { .search-clear {
position: absolute; position: absolute;
right: 260rpx; right: 12rpx;
top: 14rpx;
width: 44rpx; width: 44rpx;
height: 44rpx; height: 44rpx;
display: flex; display: flex;
@@ -297,7 +323,7 @@ onShow(() => {
.type-picker-text { .type-picker-text {
font-size: 24rpx; font-size: 24rpx;
color: $text-secondary; color: $text-secondary;
max-width: 80rpx; max-width: 120rpx;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;

View File

@@ -137,7 +137,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import CustomNavBar from '../../components/CustomNavBar.vue' import CustomNavBar from '../../components/CustomNavBar.vue'
import { useAdminStore } from '../../stores/admin' import { useAdminStore } from './stores/admin'
import { formatPrice, formatDateTime } from '../../utils/format' import { formatPrice, formatDateTime } from '../../utils/format'
import { OrderStatus } from '@mp-pilates/shared' import { OrderStatus } from '@mp-pilates/shared'
import type { OrderWithDetails } from '@mp-pilates/shared' import type { OrderWithDetails } from '@mp-pilates/shared'

View File

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

View File

@@ -162,7 +162,7 @@ import { ref, computed, onMounted } from 'vue'
import type { ScheduleSlotPreview } from '@mp-pilates/shared' import type { ScheduleSlotPreview } from '@mp-pilates/shared'
import CustomNavBar from '../../components/CustomNavBar.vue' import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system' import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from '../../stores/admin' import { useAdminStore } from './stores/admin'
import { formatDate } from '../../utils/format' import { formatDate } from '../../utils/format'
import DateSelector from '../../components/DateSelector.vue' import DateSelector from '../../components/DateSelector.vue'
import { import {
@@ -170,7 +170,7 @@ import {
timeToPickerIndex, timeToPickerIndex,
pickerIndexToTime, pickerIndexToTime,
addOneHourCapped, addOneHourCapped,
} from '../../utils/schedule-time' } from './utils/schedule-time'
const timePickerRange = SCHEDULE_TIME_PICKER_RANGE const timePickerRange = SCHEDULE_TIME_PICKER_RANGE

View File

@@ -152,14 +152,14 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import CustomNavBar from '../../components/CustomNavBar.vue' import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system' import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from '../../stores/admin' import { useAdminStore } from './stores/admin'
import { formatDate } from '../../utils/format' import { formatDate } from '../../utils/format'
import type { TimeSlot } from '@mp-pilates/shared' import type { TimeSlot } from '@mp-pilates/shared'
import { import {
SCHEDULE_TIME_PICKER_RANGE, SCHEDULE_TIME_PICKER_RANGE,
timeToPickerIndex, timeToPickerIndex,
pickerIndexToTime, pickerIndexToTime,
} from '../../utils/schedule-time' } from './utils/schedule-time'
const timePickerRange = SCHEDULE_TIME_PICKER_RANGE const timePickerRange = SCHEDULE_TIME_PICKER_RANGE

View File

@@ -1,7 +1,9 @@
import type { ReviewEntry, ReviewSummary } from '@mp-pilates/shared'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import { get, post, put, del } from '../utils/request' import { get, post, put, del } from '../../../utils/request'
import type { import type {
TeachingAnalytics,
CardType, CardType,
CreateCardTypeDto, CreateCardTypeDto,
UpdateCardTypeDto, UpdateCardTypeDto,
@@ -13,9 +15,6 @@ import type {
PaginatedData, PaginatedData,
ScheduleSlotPreview, ScheduleSlotPreview,
PublishDaySlotsDto, PublishDaySlotsDto,
FlashSaleAdminItem,
CreateFlashSaleDto,
UpdateFlashSaleDto,
CreateStudioUploadCredentialDto, CreateStudioUploadCredentialDto,
StudioUploadCredential, StudioUploadCredential,
AdminMemberSummary, AdminMemberSummary,
@@ -55,12 +54,6 @@ function normalizePaginatedData<T>(
} }
} }
export interface AdminStats {
todayBookings: number
totalOrders: number
totalBookings: number
}
export type MemberSummary = AdminMemberSummary export type MemberSummary = AdminMemberSummary
export interface UserMembership { export interface UserMembership {
@@ -83,6 +76,9 @@ export interface UserMembership {
} }
export const useAdminStore = defineStore('admin', () => { export const useAdminStore = defineStore('admin', () => {
async function fetchReviews(userId = '', page = 1) { return get<{ data: ReviewEntry[]; total: number }>('/admin/reviews', { ...(userId ? { userId } : {}), page }) }
async function fetchReviewTrend(month: string) { return get<ReviewSummary[]>('/admin/reviews/trend', { month }) }
// ── Card types ─────────────────────────────────────────────────── // ── Card types ───────────────────────────────────────────────────
const cardTypes = ref<CardType[]>([]) const cardTypes = ref<CardType[]>([])
@@ -262,32 +258,14 @@ export const useAdminStore = defineStore('admin', () => {
await fetchSchedulePreview(dto.date) await fetchSchedulePreview(dto.date)
} }
// ── Dashboard stats ────────────────────────────────────────────── // ── Teaching analytics ─────────────────────────────────────────
async function fetchDashboardStats(): Promise<AdminStats> { async function fetchTeachingAnalytics(month: string): Promise<TeachingAnalytics> {
return get<AdminStats>('/admin/stats') return get<TeachingAnalytics>('/admin/teaching-analytics', { month })
}
// ── Flash sales ─────────────────────────────────────────────────
async function fetchFlashSales(params?: {
page?: number
limit?: number
}): Promise<PaginatedData<FlashSaleAdminItem>> {
return get<PaginatedData<FlashSaleAdminItem>>('/admin/flash-sales', params as Record<string, unknown>)
}
async function createFlashSale(dto: CreateFlashSaleDto): Promise<FlashSaleAdminItem> {
return post<FlashSaleAdminItem>('/admin/flash-sales', dto as unknown as Record<string, unknown>)
}
async function updateFlashSale(id: string, dto: UpdateFlashSaleDto): Promise<FlashSaleAdminItem> {
return put<FlashSaleAdminItem>(`/admin/flash-sales/${id}`, dto as unknown as Record<string, unknown>)
}
async function deleteFlashSale(id: string): Promise<{ deleted: boolean }> {
return del<{ deleted: boolean }>(`/admin/flash-sales/${id}`)
} }
return { return {
fetchReviews, fetchReviewTrend,
fetchTeachingAnalytics,
// State // State
cardTypes, cardTypes,
studioConfig, studioConfig,
@@ -326,12 +304,5 @@ export const useAdminStore = defineStore('admin', () => {
fetchSchedulePreview, fetchSchedulePreview,
previewScheduleByDate, previewScheduleByDate,
publishDaySlots, publishDaySlots,
// Stats
fetchDashboardStats,
// Flash sales
fetchFlashSales,
createFlashSale,
updateFlashSale,
deleteFlashSale,
} }
}) })

View File

@@ -197,9 +197,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import CustomNavBar from '../../components/CustomNavBar.vue' import CustomNavBar from '../../components/CustomNavBar.vue'
import { useAdminStore } from '../../stores/admin' import { useAdminStore } from './stores/admin'
import { getErrorMessage } from '../../utils/auth' import { getErrorMessage } from '../../utils/auth'
import { uploadStudioAsset } from '../../utils/studio-upload' import { uploadStudioAsset } from './utils/studio-upload'
import { getSystemLayout } from '../../utils/system' import { getSystemLayout } from '../../utils/system'
type FormState = { type FormState = {

View File

@@ -89,6 +89,16 @@
</view> </view>
</view> </view>
<ClassReviewForm v-if="booking && booking.userId === userStore.user?.id && booking.status === BookingStatus.COMPLETED" :key="booking.id" :booking-id="booking.id" />
<view v-if="canSubscribeClassReminder" class="panel">
<button class="review-subscribe" @tap="subscribeClassReminder">🔔 订阅开课前 1 小时微信提醒</button>
</view>
<view v-if="canSubscribeReview" class="panel">
<button class="review-subscribe" @tap="subscribeReview">{{ booking?.status === BookingStatus.COMPLETED ? '如果现在不评24 小时后提醒我' : '订阅课后评价提醒' }}</button>
</view>
<view v-if="isAdmin && booking?.status === BookingStatus.COMPLETED" class="panel">
<button class="review-subscribe" @tap="openProgress">为这次课写评语</button>
</view>
<view v-if="showReminders" class="panel"> <view v-if="showReminders" class="panel">
<text class="panel-title">上课前</text> <text class="panel-title">上课前</text>
<view v-for="(item, index) in reminderNotes" :key="item" class="note-row"> <view v-for="(item, index) in reminderNotes" :key="item" class="note-row">
@@ -179,8 +189,8 @@
</view> </view>
<BookingConfirmPopup <BookingConfirmPopup
v-if="isSlotMode" v-if="showConfirmPopup"
:visible="showConfirmPopup" :visible="true"
:time-slot="slotData" :time-slot="slotData"
:memberships="userStore.activeMemberships as MembershipWithCardType[]" :memberships="userStore.activeMemberships as MembershipWithCardType[]"
@confirm="onConfirmBooking" @confirm="onConfirmBooking"
@@ -199,7 +209,7 @@ import type {
TimeSlotWithBookingStatus, TimeSlotWithBookingStatus,
MembershipWithCardType, MembershipWithCardType,
} from '@mp-pilates/shared' } from '@mp-pilates/shared'
import { BookingStatus, TimeSlotStatus } from '@mp-pilates/shared' import { BookingStatus, TimeSlotStatus, SubscriptionMessageScene } from '@mp-pilates/shared'
import { useBookingStore } from '../../stores/booking' import { useBookingStore } from '../../stores/booking'
import { useUserStore } from '../../stores/user' import { useUserStore } from '../../stores/user'
import { getErrorMessage } from '../../utils/auth' import { getErrorMessage } from '../../utils/auth'
@@ -212,8 +222,42 @@ import {
bookingTimelineDotClass, bookingTimelineDotClass,
} from '../../utils/booking-helpers' } from '../../utils/booking-helpers'
import CustomNavBar from '../../components/CustomNavBar.vue' import CustomNavBar from '../../components/CustomNavBar.vue'
import ClassReviewForm from '../../components/ClassReviewForm.vue'
import BookingConfirmPopup from '../../components/BookingConfirmPopup.vue' import BookingConfirmPopup from '../../components/BookingConfirmPopup.vue'
import {
requestSubscriptionMessage,
requestBookingCancelSubscriptionMessage,
requestClassReminderSubscriptionMessage,
cacheSubscriptionMessageTemplateConfig,
} from '../../utils/wechat-subscription'
import { get } from '../../utils/request'
import type { SubscriptionMessageTemplateConfig } from '@mp-pilates/shared'
const canSubscribeClassReminder = computed(() => {
if (!booking.value || booking.value.status !== BookingStatus.CONFIRMED) return false
if (booking.value.userId !== userStore.user?.id) return false
const slot = booking.value.timeSlot
if (!slot) return false
return !isSlotPast(slot.date, slot.startTime)
})
async function subscribeClassReminder() {
try {
const results = await requestClassReminderSubscriptionMessage()
uni.showToast({
title: results.some((r) => r.result === 'accept') ? '上课提醒已开启' : '暂未开启提醒',
icon: 'none',
})
} catch {
uni.showToast({ title: '订阅失败,请稍后重试', icon: 'none' })
}
}
async function subscribeReview() {
try { const results = await requestSubscriptionMessage(SubscriptionMessageScene.CLASS_REVIEW); uni.showToast({ title: results.some(r => r.result === 'accept') ? '提醒已开启' : '暂未开启提醒', icon: 'none' }) } catch { uni.showToast({ title: '订阅失败,请稍后重试', icon: 'none' }) }
}
function openProgress() { if (booking.value) uni.navigateTo({ url: `/pages/admin/member-progress?userId=${booking.value.userId}&bookingId=${booking.value.id}` }) }
const bookingStore = useBookingStore() const bookingStore = useBookingStore()
const userStore = useUserStore() const userStore = useUserStore()
@@ -232,6 +276,12 @@ const slotData = ref<TimeSlotWithBookingStatus | null>(null)
const showConfirmPopup = ref(false) const showConfirmPopup = ref(false)
const isAdmin = computed(() => userStore.isAdmin) const isAdmin = computed(() => userStore.isAdmin)
const canSubscribeReview = computed(() => {
const current = booking.value
if (!current || current.userId !== userStore.user?.id) return false
if (current.status === BookingStatus.CONFIRMED) return true
return current.status === BookingStatus.COMPLETED && !current.review
})
const showActions = computed(() => const showActions = computed(() =>
booking.value?.status === BookingStatus.PENDING_CONFIRMATION || booking.value?.status === BookingStatus.PENDING_CONFIRMATION ||
booking.value?.status === BookingStatus.CONFIRMED, booking.value?.status === BookingStatus.CONFIRMED,
@@ -584,6 +634,12 @@ async function handleNoShow() {
} }
async function handleCancel() { async function handleCancel() {
try {
await requestBookingCancelSubscriptionMessage()
} catch (err: unknown) {
console.warn('[subscribe] cancel pre-subscribe failed', err)
}
uni.showModal({ uni.showModal({
title: '取消预约', title: '取消预约',
content: '确定要取消该预约?', content: '确定要取消该预约?',
@@ -614,6 +670,7 @@ onMounted(async () => {
}) })
onLoad((query) => { onLoad((query) => {
void get<SubscriptionMessageTemplateConfig>('/user/subscription-messages/templates').then(cacheSubscriptionMessageTemplateConfig).catch(() => {})
updateLayout() updateLayout()
const q = query as Record<string, string> const q = query as Record<string, string>
@@ -637,6 +694,7 @@ function updateLayout() {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.review-subscribe {font-size:26rpx;background:#eaf0e8;color:#536f60;border-radius:20rpx;min-height:88rpx;line-height:88rpx;&::after{border:0;}}
.page { .page {
height: 100vh; height: 100vh;
box-sizing: border-box; box-sizing: border-box;
@@ -1003,7 +1061,8 @@ function updateLayout() {
} }
&--dock-tall { &--dock-tall {
height: 320rpx; height: calc(280rpx + env(safe-area-inset-bottom));
min-height: calc(128px + env(safe-area-inset-bottom));
} }
} }
@@ -1047,7 +1106,13 @@ function updateLayout() {
.dock-row { .dock-row {
display: flex; display: flex;
gap: 12rpx; flex-shrink: 0;
gap: 16rpx;
.dock-btn {
flex: 1 1 0;
min-width: 0;
}
} }
.dock--slot .dock-btn { .dock--slot .dock-btn {
@@ -1057,9 +1122,13 @@ function updateLayout() {
} }
.dock-btn { .dock-btn {
flex: 1; // The dock stacks vertically; only horizontal rows should distribute space.
height: 80rpx; flex: none;
border-radius: 999rpx; box-sizing: border-box;
height: 96rpx;
min-height: 48px;
padding: 0 24rpx;
border-radius: 20rpx;
background: #6b8276; background: #6b8276;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1071,7 +1140,7 @@ function updateLayout() {
&--ghost { &--ghost {
background: #fff; background: #fff;
border: 1rpx solid #eee8e3; border: 1rpx solid #d8cec5;
} }
&--danger { &--danger {

View File

@@ -22,6 +22,8 @@
<scroll-view <scroll-view
class="slot-scroll" class="slot-scroll"
scroll-y scroll-y
:scroll-into-view="targetSlotId"
scroll-with-animation
refresher-enabled refresher-enabled
:refresher-triggered="refreshing" :refresher-triggered="refreshing"
@refresherrefresh="onRefresh" @refresherrefresh="onRefresh"
@@ -52,15 +54,19 @@
<text class="date-summary-text">{{ filteredSlots.length }} 个时段</text> <text class="date-summary-text">{{ filteredSlots.length }} 个时段</text>
</view> </view>
<SlotCard <view
v-for="item in filteredSlots" v-for="item in filteredSlots"
:id="`slot-${item.id}`"
:key="item.id" :key="item.id"
>
<SlotCard
:time-slot="item" :time-slot="item"
@book="onBookTap" @book="onBookTap"
@cancel="onCancelTap" @cancel="onCancelTap"
@card-tap="onSlotCardTap" @card-tap="onSlotCardTap"
/> />
</view> </view>
</view>
<!-- Bottom padding spacer --> <!-- Bottom padding spacer -->
<view class="scroll-bottom-spacer" /> <view class="scroll-bottom-spacer" />
@@ -68,7 +74,8 @@
<!-- Confirm popup --> <!-- Confirm popup -->
<BookingConfirmPopup <BookingConfirmPopup
:visible="showConfirmPopup" v-if="showConfirmPopup"
:visible="true"
:time-slot="pendingSlot" :time-slot="pendingSlot"
:memberships="userStore.activeMemberships as MembershipWithCardType[]" :memberships="userStore.activeMemberships as MembershipWithCardType[]"
@confirm="onConfirmBooking" @confirm="onConfirmBooking"
@@ -78,19 +85,20 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted, nextTick, getCurrentInstance } from 'vue'
import { onResize, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app' import { onResize, onShareAppMessage, onShareTimeline, onShow } from '@dcloudio/uni-app'
import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pilates/shared' import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pilates/shared'
import { BookingStatus, TIME_PERIODS } from '@mp-pilates/shared' import { BookingStatus, TIME_PERIODS } from '@mp-pilates/shared'
import { useBookingStore } from '../../stores/booking' import { useBookingStore } from '../../stores/booking'
import { useUserStore } from '../../stores/user' import { useUserStore } from '../../stores/user'
import { getErrorMessage } from '../../utils/auth' import { getErrorMessage } from '../../utils/auth'
import { formatDate } from '../../utils/format' import { formatDate, isSlotPast } from '../../utils/format'
import { getSystemLayout } from '../../utils/system' import { getSystemLayout } from '../../utils/system'
import DateSelector from '../../components/DateSelector.vue' import DateSelector from '../../components/DateSelector.vue'
import TimePeriodFilter from '../../components/TimePeriodFilter.vue' import TimePeriodFilter from '../../components/TimePeriodFilter.vue'
import SlotCard from '../../components/SlotCard.vue' import SlotCard from '../../components/SlotCard.vue'
import BookingConfirmPopup from '../../components/BookingConfirmPopup.vue' import BookingConfirmPopup from '../../components/BookingConfirmPopup.vue'
import { requestBookingCancelSubscriptionMessage } from '../../utils/wechat-subscription'
type PeriodKey = keyof typeof TIME_PERIODS | null type PeriodKey = keyof typeof TIME_PERIODS | null
@@ -104,6 +112,10 @@ const selectedPeriod = ref<PeriodKey>(null)
const showConfirmPopup = ref(false) const showConfirmPopup = ref(false)
const pendingSlot = ref<TimeSlotWithBookingStatus | null>(null) const pendingSlot = ref<TimeSlotWithBookingStatus | null>(null)
const refreshing = ref(false) const refreshing = ref(false)
const targetSlotId = ref('')
// 仅在「每次启动首次进入预约 TAB」时自动定位到当前时段及以后
// 切换日期/时段或下拉刷新后不再重置位置,避免打断用户的浏览位置。
const hasAutoScrolled = ref(false)
// ─── 微信分享 ─────────────────────────────────────────────── // ─── 微信分享 ───────────────────────────────────────────────
onShareAppMessage(() => { onShareAppMessage(() => {
@@ -167,6 +179,94 @@ async function onRefresh() {
refreshing.value = false refreshing.value = false
} }
const instance = getCurrentInstance()
let isAutoScrolling = false
/**
* 轮询等待目标节点在视图层完成挂载与排版
*/
function waitForElement(selector: string, maxRetries = 10, interval = 50): Promise<boolean> {
return new Promise((resolve) => {
let retries = 0
function check() {
const query = instance?.proxy
? uni.createSelectorQuery().in(instance.proxy)
: uni.createSelectorQuery()
const q = query
.select(selector)
.boundingClientRect((data) => {
const node = Array.isArray(data) ? data[0] : data
if (node && node.top !== undefined) {
resolve(true)
} else if (retries < maxRetries) {
retries++
setTimeout(check, interval)
} else {
resolve(false)
}
})
// 触发查询
q['exec']()
}
check()
})
}
// 首次进入时滚动到当天第一个未开始的课程("本时段及以后"
async function scrollToUpcoming() {
if (hasAutoScrolled.value || isAutoScrolling) return
if (bookingStore.loadingSlots) return
const slots = filteredSlots.value
if (slots.length === 0) {
// 列表还没加载出来(加载中或当天无课),不要把「仅一次」用掉。
return
}
const upcomingIndex = slots.findIndex((slot) => !isSlotPast(slot.date, slot.startTime))
if (upcomingIndex === -1) {
// 当天所有课程均已结束,标记已滚动过,留在当前位置
hasAutoScrolled.value = true
return
}
if (upcomingIndex === 0) {
// 本时段及以后的第一个课程正好是列表第 1 项,页面已经在顶部,无需额外滚动
hasAutoScrolled.value = true
return
}
isAutoScrolling = true
const dateWhenStarted = selectedDate.value
const upcoming = slots[upcomingIndex]
const targetId = `slot-${upcoming.id}`
try {
// 等待 Vue 虚拟 DOM 提交并分发 setData
await nextTick()
// 确保原生视图层已完成该节点的挂载与布局排版
const isReady = await waitForElement(`#${targetId}`, 10, 50)
if (!isReady || selectedDate.value !== dateWhenStarted) {
// 节点尚未在视图层就绪(例如 Tab 处于后台未完成渲染)或用户已切换日期,不锁定 hasAutoScrolled留待 onShow 或后续就绪时执行
return
}
if (targetSlotId.value === targetId) {
targetSlotId.value = ''
await new Promise((resolve) => setTimeout(resolve, 50))
if (selectedDate.value !== dateWhenStarted) return
}
targetSlotId.value = targetId
hasAutoScrolled.value = true
} finally {
isAutoScrolling = false
}
}
// ─── Event handlers ─────────────────────────────────────── // ─── Event handlers ───────────────────────────────────────
function onDateSelect(date: string) { function onDateSelect(date: string) {
selectedDate.value = date selectedDate.value = date
@@ -269,6 +369,12 @@ async function onConfirmBooking(payload: { timeSlotId: string; membershipId: str
async function onCancelTap(slot: TimeSlotWithBookingStatus) { async function onCancelTap(slot: TimeSlotWithBookingStatus) {
if (!slot.myBookingId) return if (!slot.myBookingId) return
try {
await requestBookingCancelSubscriptionMessage()
} catch (err: unknown) {
console.warn('[subscribe] cancel pre-subscribe failed', err)
}
uni.showModal({ uni.showModal({
title: '取消预约', title: '取消预约',
content: '确定要取消这个预约吗?', content: '确定要取消这个预约吗?',
@@ -295,12 +401,21 @@ async function onCancelTap(slot: TimeSlotWithBookingStatus) {
// ─── Lifecycle ──────────────────────────────────────────── // ─── Lifecycle ────────────────────────────────────────────
onMounted(async () => { onMounted(async () => {
const tasks: Promise<unknown>[] = [loadSlots(selectedDate.value)]
// Load memberships if logged in but not yet fetched // Load memberships if logged in but not yet fetched
if (userStore.loggedIn && userStore.activeMemberships.length === 0) { if (userStore.loggedIn && userStore.activeMemberships.length === 0) {
await userStore.fetchMemberships() tasks.push(userStore.fetchMemberships())
}
await Promise.all(tasks)
// 首次进入:自动定位到当天本时段及以后的第一个课程
await scrollToUpcoming()
})
onShow(async () => {
// 如果首次进入时页面在后台预加载完成,或从其他 Tab 切入时定位未生效,在页面可见时触发定位
if (!hasAutoScrolled.value && filteredSlots.value.length > 0 && !bookingStore.loadingSlots) {
await scrollToUpcoming()
} }
// Load today's slots
await loadSlots(selectedDate.value)
}) })
</script> </script>

View File

@@ -5,6 +5,26 @@
:style="{ paddingTop: navBarHeight }" :style="{ paddingTop: navBarHeight }"
> >
<CustomNavBar :title="pageTitle" show-back /> <CustomNavBar :title="pageTitle" show-back />
<view class="invite-banner" @tap="inviteVisible = true; inviteInput = invite.pendingCode">
<text>{{ invite.eligible ? '好友礼遇 · 已享 95 折' : '好友礼遇 · 领取 95 折购卡优惠' }}</text>
<text class="invite-banner-note">{{ invite.eligible ? '体验卡、次卡、期限卡均适用' : '填写邀请码,和朋友一起开始练习 ' }}</text>
</view>
<view v-if="!loading && (card || allCards.length)" class="card-share-row">
<button class="card-share-button" open-type="share" aria-label="分享会员卡给微信好友或群聊">
<text class="card-share-icon"></text><text>分享给好友 / 群聊</text>
</button>
</view>
<view v-if="inviteVisible" class="purchase-sheet-layer" @touchmove.stop.prevent>
<view class="purchase-sheet">
<text class="sheet-kicker">A GIFT FROM YOUR FRIEND</text>
<text class="sheet-title">朋友送你一份 95 折礼遇</text>
<text class="invite-banner-note">确认邀请码后购卡自动减免 5%体验卡也可享受</text>
<input v-model="inviteInput" class="invite-input" maxlength="6" placeholder="填写 6 位好友邀请码" :disabled="inviteBusy" />
<text v-if="inviteError" class="invite-error">{{ inviteError }}</text>
<text class="sheet-note-text">每人仅绑定一位好友首次成功购买非体验卡好友获得 1 节免费课体验卡不触发赠课</text>
<view class="sheet-actions"><button class="sheet-cancel" :disabled="inviteBusy" @tap="inviteVisible = false">稍后再说</button><button class="sheet-confirm" :disabled="inviteBusy || inviteInput.length !== 6" @tap="confirmInvite">{{ inviteBusy ? '领取中' : '确认领取' }}</button></view>
</view>
</view>
<view v-if="loading" class="loading-wrap"> <view v-if="loading" class="loading-wrap">
<view class="skeleton-pass" /> <view class="skeleton-pass" />
@@ -64,10 +84,11 @@
<text class="card-benefit">{{ cardAccessLabel(c) }}</text> <text class="card-benefit">{{ cardAccessLabel(c) }}</text>
<view class="card-price-row"> <view class="card-price-row">
<view class="price-stack"> <view class="price-stack">
<text class="card-price">¥{{ formatPrice(c.price) }}</text> <text class="card-price">¥{{ formatPrice(invite.pendingCode && !invite.eligible ? Math.round(c.price * 95 / 100) : invite.price(c.price)) }}</text>
<text v-if="getSavingsLabel(c)" class="save-tag">{{ getSavingsLabel(c) }}</text> <text v-if="invite.pendingCode && !invite.eligible" class="save-tag">领取后 95 </text>
<text v-if="(!invite.pendingCode || invite.eligible) && getSavingsLabel(c)" class="save-tag">{{ getSavingsLabel(c) }}</text>
</view> </view>
<text class="card-unit">{{ getUnitPriceLabel(c) }}</text> <text v-if="!invite.pendingCode || invite.eligible" class="card-unit">{{ getUnitPriceLabel(c) }}</text>
</view> </view>
</view> </view>
@@ -129,7 +150,7 @@
<view class="hero-price-row"> <view class="hero-price-row">
<text class="hero-currency">¥</text> <text class="hero-currency">¥</text>
<text class="hero-price">{{ formatPrice(cardData.price) }}</text> <text class="hero-price">{{ formatPrice(invite.price(cardData.price)) }}</text>
<text <text
v-if="cardData.originalPrice && cardData.originalPrice > cardData.price" v-if="cardData.originalPrice && cardData.originalPrice > cardData.price"
class="hero-original" class="hero-original"
@@ -239,7 +260,7 @@
<view class="bottom-bar" :class="{ 'bottom-bar--renew': isRenewal }"> <view class="bottom-bar" :class="{ 'bottom-bar--renew': isRenewal }">
<view class="price-summary"> <view class="price-summary">
<text class="price-summary-label">{{ isRenewal ? '续卡金额' : '实付金额' }}</text> <text class="price-summary-label">{{ isRenewal ? '续卡金额' : '实付金额' }}</text>
<text class="price-summary-value">¥{{ formatPrice(cardData.price) }}</text> <text class="price-summary-value">¥{{ formatPrice(invite.price(cardData.price)) }}</text>
<text class="price-summary-hint">{{ bottomPriceHint }}</text> <text class="price-summary-hint">{{ bottomPriceHint }}</text>
</view> </view>
<button <button
@@ -270,7 +291,7 @@
<text class="sheet-card-type">{{ typeLabel }}</text> <text class="sheet-card-type">{{ typeLabel }}</text>
<text class="sheet-card-name">{{ cardData.name }}</text> <text class="sheet-card-name">{{ cardData.name }}</text>
</view> </view>
<text class="sheet-card-price">¥{{ formatPrice(cardData.price) }}</text> <text class="sheet-card-price">¥{{ formatPrice(invite.price(cardData.price)) }}</text>
</view> </view>
<view class="sheet-card-meta"> <view class="sheet-card-meta">
<text>{{ cardAccessLabel(cardData) }}</text> <text>{{ cardAccessLabel(cardData) }}</text>
@@ -319,8 +340,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useInviteStore } from '../../stores/invite'
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { onShow } from '@dcloudio/uni-app' import { onLoad, onShow, onShareAppMessage } from '@dcloudio/uni-app'
import type { CardType, CreateOrderResponse } from '@mp-pilates/shared' import type { CardType, CreateOrderResponse } from '@mp-pilates/shared'
import { import {
CardTypeCategory, CardTypeCategory,
@@ -335,7 +357,7 @@ import { get, post } from '../../utils/request'
import { formatPrice, getCardCoverClass } from '../../utils/format' import { formatPrice, getCardCoverClass } from '../../utils/format'
import { getSystemLayout } from '../../utils/system' import { getSystemLayout } from '../../utils/system'
import { useUserStore } from '../../stores/user' import { useUserStore } from '../../stores/user'
import { requestOrderPaidSubscriptionMessage } from '../../utils/wechat-subscription' import { requestBookingCreatedSubscriptionMessage } from '../../utils/wechat-subscription'
import CustomNavBar from '../../components/CustomNavBar.vue' import CustomNavBar from '../../components/CustomNavBar.vue'
interface MyOrderStatusResponse { interface MyOrderStatusResponse {
@@ -348,6 +370,31 @@ interface MyOrderStatusResponse {
type PaymentConfirmationResult = 'paid' | 'pending' | 'unavailable' | 'unauthorized' type PaymentConfirmationResult = 'paid' | 'pending' | 'unavailable' | 'unauthorized'
type PaymentStatusIssue = 'none' | 'order-check' | 'membership-sync' | 'reauthentication' type PaymentStatusIssue = 'none' | 'order-check' | 'membership-sync' | 'reauthentication'
const invite = useInviteStore()
const inviteVisible = ref(false)
const inviteInput = ref('')
const inviteBusy = ref(false)
const inviteError = ref('')
onLoad((options) => {
uni.showShareMenu({ menus: ['shareAppMessage'] })
if (options?.inviteCode) {
invite.pendingCode = options.inviteCode.toUpperCase()
inviteInput.value = invite.pendingCode
inviteVisible.value = true
}
})
async function confirmInvite() {
if (inviteBusy.value) return
inviteBusy.value = true
inviteError.value = ''
try {
if (!userStore.loggedIn) await userStore.login()
await invite.confirm(inviteInput.value.trim().toUpperCase())
inviteVisible.value = false
uni.showToast({ title: '95 折优惠已领取', icon: 'success' })
} catch (err) { inviteError.value = getErrorMessage(err, '领取失败,请重试') }
finally { inviteBusy.value = false }
}
const userStore = useUserStore() const userStore = useUserStore()
const navBarHeight = ref('64px') const navBarHeight = ref('64px')
const cardId = ref('') const cardId = ref('')
@@ -369,6 +416,22 @@ const paymentRedirecting = ref(false)
const paymentConfirmationSession = ref(0) const paymentConfirmationSession = ref(0)
const failedCoverIds = ref<Set<string>>(new Set()) const failedCoverIds = ref<Set<string>>(new Set())
onShareAppMessage(() => {
const sharedCard = showAll.value ? null : card.value
const id = sharedCard?.id || (!showAll.value ? cardId.value : '')
const path = id
? `/pages/card/detail?id=${encodeURIComponent(id)}`
: isTrialEntry.value && !showAll.value
? '/pages/card/detail?trial=1'
: '/pages/card/detail?showAll=1'
return {
title: sharedCard ? `${sharedCard.name} · 一起练普拉提` : '选择你的普拉提会员卡,一起开始练习',
path,
...(sharedCard && hasCardCover(sharedCard) ? { imageUrl: sharedCard.coverUrl! } : {}),
}
})
const pageTitle = computed(() => { const pageTitle = computed(() => {
if (showAll.value) return '选择会员卡' if (showAll.value) return '选择会员卡'
return isRenewal.value ? '续卡' : '购买会员卡' return isRenewal.value ? '续卡' : '购买会员卡'
@@ -578,7 +641,7 @@ function cardAccessLabel(cardType: CardType): string {
function getUnitPrice(cardType: CardType): string { function getUnitPrice(cardType: CardType): string {
const divisor = cardType.totalTimes === null ? cardType.durationDays : cardType.totalTimes const divisor = cardType.totalTimes === null ? cardType.durationDays : cardType.totalTimes
if (!divisor) return '-' if (!divisor) return '-'
return '¥' + String(Math.round(cardType.price / divisor / 100)) return '¥' + String(Math.round(invite.price(cardType.price) / divisor / 100))
} }
function getUnitPriceLabel(cardType: CardType): string { function getUnitPriceLabel(cardType: CardType): string {
@@ -586,6 +649,7 @@ function getUnitPriceLabel(cardType: CardType): string {
} }
function getSavingsLabel(cardType: CardType): string { function getSavingsLabel(cardType: CardType): string {
if (invite.eligible) return '好友价 · 95 折'
if (!cardType.originalPrice || cardType.originalPrice <= cardType.price) return '' if (!cardType.originalPrice || cardType.originalPrice <= cardType.price) return ''
return '省 ¥' + formatPrice(cardType.originalPrice - cardType.price) return '省 ¥' + formatPrice(cardType.originalPrice - cardType.price)
} }
@@ -698,6 +762,7 @@ async function preparePurchaseConfirmation() {
if (!card.value || isPurchaseBusy.value) return if (!card.value || isPurchaseBusy.value) return
purchasePreparing.value = true purchasePreparing.value = true
try { try {
await invite.refresh()
const refreshed = await refreshMembershipContext() const refreshed = await refreshMembershipContext()
if (!userStore.loggedIn) { if (!userStore.loggedIn) {
showLoginPrompt() showLoginPrompt()
@@ -708,6 +773,8 @@ async function preparePurchaseConfirmation() {
return return
} }
purchaseConfirmVisible.value = true purchaseConfirmVisible.value = true
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '暂时无法核对优惠,请重试'), icon: 'none' })
} finally { } finally {
purchasePreparing.value = false purchasePreparing.value = false
} }
@@ -821,13 +888,16 @@ async function doPurchase() {
paymentRedirecting.value = false paymentRedirecting.value = false
paymentConfirmationSession.value++ paymentConfirmationSession.value++
pendingOrderId.value = '' pendingOrderId.value = ''
uni.showLoading({ title: '创建订单...' })
try { try {
const inviterId = uni.getStorageSync('invite_inviter_id') as string // 必须在 tap 同步栈里调起订阅框;失败不打断支付。
await requestBookingCreatedSubscriptionMessage().catch((error) => {
console.warn('[subscribe] purchase pre-subscribe failed', error)
})
uni.showLoading({ title: '创建订单...' })
const result = await post<CreateOrderResponse>('/payment/create-order', { const result = await post<CreateOrderResponse>('/payment/create-order', {
cardTypeId: card.value.id, cardTypeId: card.value.id,
inviterId: isTrialCard.value && inviterId ? inviterId : undefined,
}) })
uni.hideLoading() uni.hideLoading()
@@ -845,7 +915,6 @@ async function doPurchase() {
}) })
pendingOrderId.value = result.order.id pendingOrderId.value = result.order.id
await requestOrderPaidSubscriptionMessage().catch(() => undefined)
await settlePaidOrder(result.order.id) await settlePaidOrder(result.order.id)
} catch (err: unknown) { } catch (err: unknown) {
uni.hideLoading() uni.hideLoading()
@@ -872,6 +941,7 @@ onMounted(() => {
}) })
onShow(() => { onShow(() => {
if (userStore.loggedIn) void invite.refresh().catch(() => {})
if (!paymentConfirming.value && !paymentPending.value) { if (!paymentConfirming.value && !paymentPending.value) {
void refreshMembershipContext() void refreshMembershipContext()
} }
@@ -883,6 +953,15 @@ onUnmounted(() => {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.card-share-row { display: flex; justify-content: flex-end; margin: 8rpx 32rpx 0; }
.card-share-button { display: flex; align-items: center; gap: 10rpx; margin: 0; padding: 12rpx 20rpx; min-height: 64rpx; line-height: 40rpx; background: transparent; color: #526e58; font-size: 23rpx; border-radius: 32rpx; &::after { border: none; } &:active { background: #eaf0e5; } }
.card-share-icon { font-size: 28rpx; }
.invite-banner { margin: 24rpx 32rpx 0; padding: 24rpx; border-radius: 20rpx; background: #eaf0e5; color: #526e58; font-size: 27rpx; }
.invite-banner-note { display: block; margin-top: 10rpx; color: #7b8974; font-size: 22rpx; line-height: 1.6; }
.invite-input { margin: 26rpx 0; padding: 24rpx; border: 1rpx dashed #a9b99e; border-radius: 16rpx; font: 34rpx monospace; letter-spacing: 6rpx; height: 55rpx; text-align: center; }
.invite-error { display: block; color: #ad604c; font-size: 23rpx; margin-bottom: 16rpx; }
.card-detail-page { .card-detail-page {
min-height: 100vh; min-height: 100vh;
box-sizing: border-box; box-sizing: border-box;

View File

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

View File

@@ -11,8 +11,8 @@
<view class="card-handle"><view class="card-handle-bar" /></view> <view class="card-handle"><view class="card-handle-bar" /></view>
<QuickEntry @scroll-to-card-shop="scrollToCardShop" /> <QuickEntry @scroll-to-card-shop="scrollToCardShop" />
<UpcomingBooking /> <UpcomingBooking />
<ReviewSummaryCard />
<StudioInfo :studio-info="studioStore.studioInfo" /> <StudioInfo :studio-info="studioStore.studioInfo" />
<FlashSaleSection ref="flashSaleRef" />
<view :id="cardShopAnchorId"> <view :id="cardShopAnchorId">
<CardShop ref="cardShopRef" /> <CardShop ref="cardShopRef" />
</view> </view>
@@ -28,10 +28,10 @@ import { ref, nextTick, onUnmounted } from 'vue'
import { onShow, onResize, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app' import { onShow, onResize, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import BrandBanner from '../../components/BrandBanner.vue' import BrandBanner from '../../components/BrandBanner.vue'
import ReviewSummaryCard from '../../components/ReviewSummaryCard.vue'
import StudioInfo from '../../components/StudioInfo.vue' import StudioInfo from '../../components/StudioInfo.vue'
import QuickEntry from '../../components/QuickEntry.vue' import QuickEntry from '../../components/QuickEntry.vue'
import UpcomingBooking from '../../components/UpcomingBooking.vue' import UpcomingBooking from '../../components/UpcomingBooking.vue'
import FlashSaleSection from '../../components/FlashSaleSection.vue'
import CardShop from '../../components/CardShop.vue' import CardShop from '../../components/CardShop.vue'
import AboutSection from '../../components/AboutSection.vue' import AboutSection from '../../components/AboutSection.vue'
@@ -62,7 +62,6 @@ onShareTimeline(() => {
// ─── Layout ─────────────────────────────────────────────── // ─── Layout ───────────────────────────────────────────────
const refreshing = ref(false) const refreshing = ref(false)
const cardShopRef = ref<InstanceType<typeof CardShop> | null>(null) const cardShopRef = ref<InstanceType<typeof CardShop> | null>(null)
const flashSaleRef = ref<InstanceType<typeof FlashSaleSection> | null>(null)
const cardShopAnchorId = 'card-shop-anchor' const cardShopAnchorId = 'card-shop-anchor'
const scrollTarget = ref('') const scrollTarget = ref('')
const pageHeight = ref(`${uni.getWindowInfo().windowHeight}px`) const pageHeight = ref(`${uni.getWindowInfo().windowHeight}px`)
@@ -103,10 +102,9 @@ async function refreshData() {
await Promise.allSettled(tasks) await Promise.allSettled(tasks)
// Also refresh card shop and flash sales // Also refresh card shop
await Promise.allSettled([ await Promise.allSettled([
cardShopRef.value?.fetchCardTypes(), cardShopRef.value?.fetchCardTypes(),
flashSaleRef.value?.fetchFlashSales(),
]) ])
} }

View File

@@ -57,7 +57,7 @@
<text class="row-end"> {{ endTime(booking) }}</text> <text class="row-end"> {{ endTime(booking) }}</text>
</view> </view>
<text class="row-stamp" :class="stampClass(booking.status)"> <text class="row-stamp" :class="stampClass(booking.status)">
{{ bookingStatusLabel(booking.status) }} {{ booking.review ? '★ ' + booking.review.rating + ' · 已评价' : bookingStatusLabel(booking.status) }}
</text> </text>
</view> </view>
<view class="row-bottom"> <view class="row-bottom">
@@ -124,7 +124,7 @@
<text class="row-end"> {{ endTime(booking) }}</text> <text class="row-end"> {{ endTime(booking) }}</text>
</view> </view>
<text class="row-stamp" :class="stampClass(booking.status)"> <text class="row-stamp" :class="stampClass(booking.status)">
{{ bookingStatusLabel(booking.status) }} {{ booking.review ? '★ ' + booking.review.rating + ' · 已评价' : bookingStatusLabel(booking.status) }}
</text> </text>
</view> </view>
<text class="row-meta">{{ historyDayLabel(booking.timeSlot.date) }} · {{ cardName(booking) }}</text> <text class="row-meta">{{ historyDayLabel(booking.timeSlot.date) }} · {{ cardName(booking) }}</text>
@@ -154,6 +154,7 @@ import {
} from '../../utils/booking-helpers' } from '../../utils/booking-helpers'
import CustomNavBar from '../../components/CustomNavBar.vue' import CustomNavBar from '../../components/CustomNavBar.vue'
import LessonSupplementList from '../../components/LessonSupplementList.vue' import LessonSupplementList from '../../components/LessonSupplementList.vue'
import { requestBookingCancelSubscriptionMessage } from '../../utils/wechat-subscription'
type TabKey = 'upcoming' | 'history' type TabKey = 'upcoming' | 'history'
@@ -327,6 +328,12 @@ function goDetail(booking: BookingWithDetails) {
} }
async function handleCancel(booking: BookingWithDetails) { async function handleCancel(booking: BookingWithDetails) {
try {
await requestBookingCancelSubscriptionMessage()
} catch (err: unknown) {
console.warn('[subscribe] cancel pre-subscribe failed', err)
}
const dateLabel = formatDateDisplay(booking.timeSlot.date) const dateLabel = formatDateDisplay(booking.timeSlot.date)
const timeLabel = startTime(booking) const timeLabel = startTime(booking)

View File

@@ -5,18 +5,19 @@
</view> </view>
<!-- User card --> <!-- User card -->
<UserCard :logged-in="loggedIn" :has-profile="hasProfile" :user="user" :stats="stats" :memberships="memberships" <UserCard :logged-in="loggedIn" :has-profile="hasProfile" :user="user" :memberships="memberships" :now="membershipNow"
:loading="loginLoading" @login="handleLogin" @edit="goToInfo" /> :memberships-loading="membershipsLoading" :memberships-loaded="membershipsLoaded" :memberships-error="membershipsError"
:loading="loginLoading" @login="handleLogin" @edit="goToInfo" @refresh-memberships="userStore.fetchMemberships()" />
<InviteCard />
<!-- Menu section: always visible --> <!-- Menu section: always visible -->
<ProfileMenu <ProfileMenu
:is-admin="isAdmin" :is-admin="isAdmin"
:require-auth="loggedIn" :require-auth="loggedIn"
:active-membership-count="activeMembershipCount"
:upcoming-booking-count="upcomingBookingCount"
:invite-share-eligible="!!user?.inviteShareEligible"
@clear-cache="handleClearCache" @clear-cache="handleClearCache"
@require-login="handleLogin" @require-login="handleLogin"
@open-notifications="showNotificationsModal = true"
> >
<PracticeActivityCard v-if="loggedIn" :key="userStore.token" :refresh-key="activityRefreshKey" /> <PracticeActivityCard v-if="loggedIn" :key="userStore.token" :refresh-key="activityRefreshKey" />
</ProfileMenu> </ProfileMenu>
@@ -25,44 +26,42 @@
<view v-if="loggedIn" class="profile-page__logout-wrap"> <view v-if="loggedIn" class="profile-page__logout-wrap">
<button class="profile-page__logout-btn" @tap="handleLogout">退出登录</button> <button class="profile-page__logout-btn" @tap="handleLogout">退出登录</button>
</view> </view>
<!-- Notification Settings Modal -->
<SubscriptionSettingsModal v-model:visible="showNotificationsModal" />
</view> </view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import InviteCard from '../../components/InviteCard.vue'
import { useInviteStore } from '../../stores/invite'
import { ref, onMounted } from 'vue'
import { onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app' import { onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useUserStore } from '../../stores/user' import { useUserStore } from '../../stores/user'
import { useBookingStore } from '../../stores/booking'
import { getSystemLayout } from '../../utils/system' import { getSystemLayout } from '../../utils/system'
import { getErrorMessage } from '../../utils/auth' import { getErrorMessage } from '../../utils/auth'
import PracticeActivityCard from '../../components/PracticeActivityCard.vue' import PracticeActivityCard from '../../components/PracticeActivityCard.vue'
import UserCard from '../../components/UserCard.vue' import UserCard from '../../components/UserCard.vue'
import ProfileMenu from '../../components/ProfileMenu.vue' import ProfileMenu from '../../components/ProfileMenu.vue'
import SubscriptionSettingsModal from '../../components/SubscriptionSettingsModal.vue'
const invite = useInviteStore()
const userStore = useUserStore() const userStore = useUserStore()
const bookingStore = useBookingStore() const { loggedIn, hasProfile, user, memberships, membershipsLoading, membershipsLoaded, membershipsError, isAdmin } = storeToRefs(userStore)
const { loggedIn, hasProfile, user, stats, memberships, isAdmin } = storeToRefs(userStore)
const { upcomingBookings } = storeToRefs(bookingStore)
const showNotificationsModal = ref(false)
const activityRefreshKey = ref(0) const activityRefreshKey = ref(0)
const membershipNow = ref(Date.now())
const loginLoading = ref(false) const loginLoading = ref(false)
const navBarHeight = ref(getSystemLayout().navBarHeight) const navBarHeight = ref(getSystemLayout().navBarHeight)
const statusBarHeight = getSystemLayout().statusBarHeight const statusBarHeight = getSystemLayout().statusBarHeight
const activeMembershipCount = computed(
() => user.value?.activeMembershipCount ?? userStore.activeMemberships.length,
)
const upcomingBookingCount = computed(
() => (loggedIn.value ? upcomingBookings.value.length : 0),
)
// ─── 微信分享 ─────────────────────────────────────────────── // ─── 微信分享 ───────────────────────────────────────────────
onShareAppMessage(() => { onShareAppMessage(() => {
return { return {
title: '我的普拉提会所,记录每一次进步', title: invite.code ? '送你 95 折购卡礼,一起练普拉提' : '一起练普拉提',
path: '/pages/profile/index', path: invite.code ? `/pages/card/detail?showAll=1&inviteCode=${invite.code}` : '/pages/home/index',
imageUrl: '', imageUrl: '',
} }
}) })
@@ -79,13 +78,14 @@ onMounted(() => {
}) })
onShow(async () => { onShow(async () => {
membershipNow.value = Date.now()
activityRefreshKey.value += 1 activityRefreshKey.value += 1
if (loggedIn.value) { if (loggedIn.value) {
await Promise.all([ await Promise.all([
invite.refresh().catch(() => {}),
invite.refreshActivity().catch(() => {}),
userStore.fetchProfile(), userStore.fetchProfile(),
userStore.fetchStats(),
userStore.fetchMemberships(), userStore.fetchMemberships(),
bookingStore.fetchUpcomingBookings(),
]) ])
} }
}) })
@@ -96,10 +96,7 @@ async function handleLogin() {
try { try {
const { isNewUser } = await userStore.loginWithSetup() const { isNewUser } = await userStore.loginWithSetup()
if (!isNewUser) { if (!isNewUser) {
await Promise.all([ await invite.refreshActivity().catch(() => {})
userStore.fetchStats(),
bookingStore.fetchUpcomingBookings(),
])
} }
} catch (err: unknown) { } catch (err: unknown) {
uni.showToast({ title: getErrorMessage(err, '登录失败,请重试'), icon: 'none' }) uni.showToast({ title: getErrorMessage(err, '登录失败,请重试'), icon: 'none' })

View File

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

@@ -2,7 +2,19 @@
<view class="membership-page" :style="{ paddingTop: navBarHeight, height: pageHeight }"> <view class="membership-page" :style="{ paddingTop: navBarHeight, height: pageHeight }">
<CustomNavBar title="我的会员卡" show-back /> <CustomNavBar title="我的会员卡" show-back />
<scroll-view class="scroll" scroll-y refresher-enabled :refresher-triggered="refreshing" @refresherrefresh="onRefresh"> <scroll-view class="scroll" scroll-y refresher-enabled :refresher-triggered="refreshing" @refresherrefresh="onRefresh">
<view v-if="loading && !refreshing && !allMemberships.length" class="loading-wrap"> <view v-if="userStore.loggedIn" class="practice-summary">
<view class="summary-heading"><text class="summary-kicker">MY PRACTICE</text><text class="summary-title">每一次练习都在积累</text></view>
<view class="summary-grid">
<view><text class="summary-value">{{ userStore.statsError ? '—' : userStore.stats?.totalBookings ?? '—' }}</text><text class="summary-label">累计上课 · </text></view>
<view><text class="summary-value">{{ userStore.statsError ? '—' : userStore.stats?.monthBookings ?? '—' }}</text><text class="summary-label">本月上课 · </text></view>
<view><text class="summary-value">{{ remainingLabel }}</text><text class="summary-label">剩余课时{{ finiteBalance > 0 || !unlimitedCount ? ' · 次' : '' }}</text></view>
</view>
<text v-if="unlimitedCount && finiteBalance > 0 && !userStore.membershipsError" class="summary-note">另有 {{ unlimitedCount }} 张有效不限次卡可在有效期内预约</text>
<button v-if="userStore.statsError" class="summary-retry" @tap="userStore.fetchStats()">练习统计暂时无法更新点击重试 </button>
</view>
<view v-if="!userStore.loggedIn" class="empty-wrap"><view class="empty-card"><text class="empty-title">登录后查看会员卡</text><button class="empty-btn" @tap="goProfile">前往个人中心</button></view></view>
<view v-else-if="userStore.membershipsError" class="empty-wrap"><view class="empty-card"><text class="empty-title">会员卡暂时未能更新</text><text class="empty-sub">请重试后查看最新余额和有效期。</text><button class="empty-btn" @tap="loadMemberships">重新加载</button></view></view>
<view v-else-if="loading && !refreshing && !allMemberships.length" class="loading-wrap">
<view v-for="i in 2" :key="i" class="skeleton-card" /> <view v-for="i in 2" :key="i" class="skeleton-card" />
</view> </view>
@@ -20,40 +32,15 @@
<text class="group-title">正在使用</text> <text class="group-title">正在使用</text>
<text class="group-count">{{ activeMemberships.length }} 张有效卡</text> <text class="group-count">{{ activeMemberships.length }} 张有效卡</text>
</view> </view>
<view v-for="m in activeMemberships" :key="m.id" class="mc" :class="cardBgClass(m.cardType.type)"> <view v-for="m in activeMemberships" :key="m.id" class="owned-card-wrap">
<view class="mc-top"> <OwnedMembershipCard :membership="m" :now="membershipNow">
<view class="mc-name-area">
<text class="mc-name">{{ m.cardType.name }}</text>
<text class="mc-type-text">{{ getCardTypeLabel(m.cardType.type) }}</text>
</view>
<text class="mc-status mc-status--active">有效</text>
</view>
<view class="mc-balance">
<view class="mc-number-row">
<text class="mc-big-num">{{ m.remainingTimes !== null ? m.remainingTimes : daysRemaining(m) }}</text>
<text class="mc-big-unit">{{ m.remainingTimes !== null ? '次可用' : '天剩余' }}</text>
</view>
<text v-if="m.remainingTimes === null" class="mc-duration-note">有效期内不限次数</text>
<view v-else-if="getMembershipTotalTimes(m)" class="mc-progress">
<view class="mc-progress-track"><view class="mc-progress-fill" :style="{ width: getMembershipProgressWidth(m) }" /></view>
<text class="mc-progress-label">已用 {{ getMembershipUsedTimes(m) }} / {{ getMembershipTotalTimes(m) }} </text>
</view>
</view>
<view class="mc-bottom">
<view class="mc-date-item">
<text class="mc-date-label">开始日期</text>
<text class="mc-date-value">{{ m.startDate.slice(0, 10) }}</text>
</view>
<view class="mc-date-item mc-date-item--end">
<text class="mc-date-label">有效期至</text>
<text class="mc-date-value">{{ m.expireDate.slice(0, 10) }}</text>
</view>
</view>
<view class="mc-actions"> <view class="mc-actions">
<button v-if="canRenewMembership(m)" class="mc-renew" @tap="goRenew(m)">续卡</button> <button v-if="canRenewMembership(m)" class="mc-renew" @tap="goRenew(m)">续卡</button>
<button class="mc-book" @tap="goBooking">预约课程</button> <button class="mc-book" @tap="goBooking">预约课程</button>
</view> </view>
</OwnedMembershipCard>
</view> </view>
<text class="usage-note">已用次数包含预约扣次不等同于已完成上课进度条表示已用次数占比</text>
</view> </view>
<view v-if="inactiveMemberships.length" class="group-section"> <view v-if="inactiveMemberships.length" class="group-section">
@@ -81,7 +68,7 @@
</view> </view>
<view class="scroll-bottom-spacer" /> <view class="scroll-bottom-spacer" />
</scroll-view> </scroll-view>
<view v-if="allMemberships.length" class="purchase-dock"> <view v-if="userStore.loggedIn && allMemberships.length" class="purchase-dock">
<button class="purchase-btn" @tap="goStore">选购会员卡</button> <button class="purchase-btn" @tap="goStore">选购会员卡</button>
</view> </view>
</view> </view>
@@ -94,15 +81,17 @@ import type { MembershipWithCardType } from '@mp-pilates/shared'
import { MembershipStatus, CardTypeCategory } from '@mp-pilates/shared' import { MembershipStatus, CardTypeCategory } from '@mp-pilates/shared'
import { useUserStore } from '../../stores/user' import { useUserStore } from '../../stores/user'
import { getSystemLayout } from '../../utils/system' import { getSystemLayout } from '../../utils/system'
import { getCardTypeLabel, getMembershipProgressWidth, getMembershipUsedTimes, getMembershipTotalTimes } from '../../utils/format' import { getCardTypeLabel } from '../../utils/format'
import CustomNavBar from '../../components/CustomNavBar.vue' import CustomNavBar from '../../components/CustomNavBar.vue'
import OwnedMembershipCard from '../../components/OwnedMembershipCard.vue'
const userStore = useUserStore() const userStore = useUserStore()
const navBarHeight = `${getSystemLayout().navBarHeight}px` const navBarHeight = `${getSystemLayout().navBarHeight}px`
const pageHeight = ref(`${uni.getWindowInfo().windowHeight}px`) const pageHeight = ref(`${uni.getWindowInfo().windowHeight}px`)
onResize(() => { pageHeight.value = `${uni.getWindowInfo().windowHeight}px` }) onResize(() => { pageHeight.value = `${uni.getWindowInfo().windowHeight}px` })
const loading = ref(false) const loading = computed(() => userStore.membershipsLoading)
const membershipNow = ref(Date.now())
const refreshing = ref(false) const refreshing = ref(false)
const allMemberships = computed(() => userStore.memberships as MembershipWithCardType[]) const allMemberships = computed(() => userStore.memberships as MembershipWithCardType[])
@@ -129,36 +118,23 @@ function inactiveStatusClass(status: MembershipStatus): string {
return 'mc-status--expired' return 'mc-status--expired'
} }
function cardBgClass(type: CardTypeCategory): string { const finiteBalance = computed(() => activeMemberships.value.reduce((sum, m) => sum + Math.max(0, m.remainingTimes ?? 0), 0))
if (type === CardTypeCategory.TRIAL) return 'mc--trial' const unlimitedCount = computed(() => activeMemberships.value.filter(m => m.remainingTimes === null).length)
if (type === CardTypeCategory.DURATION) return 'mc--duration' const remainingLabel = computed(() => !userStore.membershipsLoaded || userStore.membershipsError ? '—' : finiteBalance.value > 0 ? finiteBalance.value : unlimitedCount.value ? '不限次' : 0)
return 'mc--times'
}
function daysRemaining(m: MembershipWithCardType): number {
const diff = new Date(m.expireDate).getTime() - Date.now()
return Math.max(0, Math.ceil(diff / 86_400_000))
}
async function loadMemberships() { async function loadMemberships() {
loading.value = true membershipNow.value = Date.now()
try { if (!userStore.loggedIn) return
await userStore.fetchMemberships() await Promise.all([userStore.fetchMemberships(), userStore.fetchStats()])
} catch {
uni.showToast({ title: '加载失败,请下拉刷新', icon: 'none' })
} finally {
loading.value = false
}
} }
async function onRefresh() { async function onRefresh() {
if (refreshing.value) return
refreshing.value = true refreshing.value = true
try { try { await loadMemberships() }
await userStore.fetchMemberships() finally { refreshing.value = false }
} finally {
refreshing.value = false
}
} }
function goProfile() { uni.switchTab({ url: '/pages/profile/index' }) }
function goBooking() { function goBooking() {
uni.switchTab({ url: '/pages/booking/index' }) uni.switchTab({ url: '/pages/booking/index' })
@@ -170,7 +146,7 @@ function goStore() {
} }
function canRenewMembership(m: MembershipWithCardType): boolean { function canRenewMembership(m: MembershipWithCardType): boolean {
return m.cardType.type !== CardTypeCategory.TRIAL return m.cardType.isActive && m.cardType.type !== CardTypeCategory.TRIAL
} }
function goRenew(m: MembershipWithCardType) { function goRenew(m: MembershipWithCardType) {
@@ -196,28 +172,12 @@ onShow(loadMemberships)
.group-title { font-size: 28rpx; font-weight: 500; } .group-title { font-size: 28rpx; font-weight: 500; }
.group-count { font-size: 22rpx; color: #8b817b; } .group-count { font-size: 22rpx; color: #8b817b; }
.mc { --balance-bg: #f3ebe3; --balance-ink: #8b6c5b; padding: 28rpx; margin-bottom: 20rpx; border: 1rpx solid #eee8e3; border-radius: 28rpx; background: #fff; } .mc { --balance-bg: #f3ebe3; --balance-ink: #8b6c5b; padding: 28rpx; margin-bottom: 20rpx; border: 1rpx solid #eee8e3; border-radius: 28rpx; background: #fff; }
.mc--duration { --balance-bg: #edf2e9; --balance-ink: #617d63; }
.mc--trial { --balance-bg: #f5e9e5; --balance-ink: #9b7768; }
.mc-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 20rpx; } .mc-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 20rpx; }
.mc-name-area { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8rpx; } .mc-name-area { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
.mc-name { font-size: 30rpx; font-weight: 500; line-height: 1.5; overflow-wrap: anywhere; } .mc-name { font-size: 30rpx; font-weight: 500; line-height: 1.5; overflow-wrap: anywhere; }
.mc-type-text { font-size: 22rpx; color: #8b817b; } .mc-type-text { font-size: 22rpx; color: #8b817b; }
.mc-status { flex-shrink: 0; padding: 6rpx 16rpx; border-radius: 999rpx; background: #ede9e4; color: #8b817b; font-size: 21rpx; line-height: 1.4; } .mc-status { flex-shrink: 0; padding: 6rpx 16rpx; border-radius: 999rpx; background: #ede9e4; color: #8b817b; font-size: 21rpx; line-height: 1.4; }
.mc-status--active { background: #edf2ec; color: #617d73; }
.mc-balance { margin-top: 24rpx; padding: 22rpx 24rpx; background: var(--balance-bg); border-radius: 20rpx; }
.mc-number-row { display: flex; align-items: baseline; gap: 10rpx; }
.mc-big-num { font-size: 56rpx; font-weight: 400; line-height: 1.1; color: var(--balance-ink); font-variant-numeric: tabular-nums; }
.mc-big-unit { font-size: 23rpx; color: var(--balance-ink); }
.mc-duration-note { display: block; font-size: 21rpx; color: #7b8775; margin-top: 12rpx; }
.mc-progress { margin-top: 18rpx; }
.mc-progress-track { height: 6rpx; border-radius: 6rpx; overflow: hidden; background: rgba(255,255,255,0.8); }
.mc-progress-fill { height: 100%; border-radius: 6rpx; background: #b9a38f; }
.mc-progress-label { display: block; margin-top: 10rpx; font-size: 21rpx; color: #8b7b70; }
.mc-bottom { display: flex; justify-content: space-between; gap: 20rpx; margin-top: 22rpx; }
.mc-date-item { min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
.mc-date-item--end { text-align: right; }
.mc-date-label { font-size: 21rpx; color: #8b817b; line-height: 1.6; } .mc-date-label { font-size: 21rpx; color: #8b817b; line-height: 1.6; }
.mc-date-value { font-size: 24rpx; color: #6f655e; font-variant-numeric: tabular-nums; }
.mc-actions { display: flex; gap: 16rpx; margin-top: 26rpx; } .mc-actions { display: flex; gap: 16rpx; margin-top: 26rpx; }
.mc-renew, .mc-book { flex: 1; margin: 0; padding: 0 24rpx; height: 72rpx; line-height: 72rpx; border: none; border-radius: 999rpx; font-size: 25rpx; font-weight: 400; &::after { border: none; } } .mc-renew, .mc-book { flex: 1; margin: 0; padding: 0 24rpx; height: 72rpx; line-height: 72rpx; border: none; border-radius: 999rpx; font-size: 25rpx; font-weight: 400; &::after { border: none; } }
.mc-renew { background: #f3eee8; color: #8b7160; } .mc-renew { background: #f3eee8; color: #8b7160; }
@@ -229,4 +189,16 @@ onShow(loadMemberships)
.purchase-dock { flex-shrink: 0; padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom)); border-top: 1rpx solid #eee8e3; background: #fbf9f6; } .purchase-dock { flex-shrink: 0; padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom)); border-top: 1rpx solid #eee8e3; background: #fbf9f6; }
.purchase-btn { display: block; width: 100%; margin: 0; padding: 0; height: 84rpx; line-height: 84rpx; border: none; border-radius: 999rpx; background: #6b8276; color: #fff; font-size: 28rpx; font-weight: 400; &::after { border: none; } } .purchase-btn { display: block; width: 100%; margin: 0; padding: 0; height: 84rpx; line-height: 84rpx; border: none; border-radius: 999rpx; background: #6b8276; color: #fff; font-size: 28rpx; font-weight: 400; &::after { border: none; } }
.scroll-bottom-spacer { height: 20rpx; } .scroll-bottom-spacer { height: 20rpx; }
.practice-summary { padding: 32rpx; margin: 24rpx 32rpx 0; border-radius: 24rpx; background: #eef1e9; border: 1rpx solid #dce3d4; color: #465c4a; }
.summary-heading { display: flex; flex-direction: column; gap: 12rpx; }
.summary-kicker { font-size: 18rpx; letter-spacing: 3rpx; color: #6d7d64; }
.summary-title { font-size: 32rpx; font-family: 'Songti SC', 'STSong', serif; }
.summary-grid { display: flex; margin-top: 30rpx; padding-top: 24rpx; border-top: 1rpx solid #d4dec9; }
.summary-grid > view { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 12rpx; text-align: center; }
.summary-grid > view + view { border-left: 1rpx solid #d4dec9; }
.summary-value { font-size: 42rpx; font-family: 'Baskerville', 'Times New Roman', serif; line-height: 1.3; font-variant-numeric: tabular-nums; }
.summary-label { font-size: 20rpx; color: #6d7d64; }
.summary-note, .usage-note { display: block; font-size: 21rpx; line-height: 1.8; color: #78816f; margin-top: 18rpx; }
.summary-retry { padding: 16rpx 0 0; margin: 0; color: #6d7d64; text-align: left; background: transparent; font-size: 23rpx; &::after { border: 0; } }
.owned-card-wrap { margin-bottom: 20rpx; }
</style> </style>

View File

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

View File

@@ -53,19 +53,20 @@
<button v-if="!isToday(selectedDate)" class="outline-button" @tap="selectDate(formatDate(new Date()))">查看今天</button> <button v-if="!isToday(selectedDate)" class="outline-button" @tap="selectDate(formatDate(new Date()))">查看今天</button>
</view> </view>
<view v-else class="agenda"> <view v-else class="agenda">
<view v-for="slot in slots" :key="slot.slotId" class="session"> <view v-for="slot in slots" :key="slot.slotId" class="session" hover-class="session--hover"
:aria-label="`${slot.startTime.slice(0, 5)} ${slot.endTime.slice(0, 5)}${slot.students.length} `" @tap="openSlot(slot.slotId)">
<view class="session__time"> <view class="session__time">
<text class="session__start">{{ slot.startTime.slice(0, 5) }}</text> <text class="session__start">{{ slot.startTime.slice(0, 5) }}</text>
<text class="session__end">{{ slot.endTime.slice(0, 5) }} 结束</text> <text class="session__end">{{ slot.endTime.slice(0, 5) }} 结束</text>
</view> </view>
<view class="session__roster"> <view class="session__roster">
<view class="session__heading"><text>预约学员</text><text>{{ slot.students.length }} </text></view> <view class="session__heading"><text>预约学员</text><text>{{ slot.students.length }} </text></view>
<view v-for="student in slot.students" :key="student.bookingId" class="student"> <view v-for="student in slot.students" :key="student.bookingId" class="student">
<view class="student__headline"> <view class="student__headline">
<text class="student__name">{{ student.nickname || '未命名学员' }}</text> <text class="student__name">{{ student.nickname || '未命名学员' }}</text>
<text class="student__status" :class="`student__status--${student.status.toLowerCase()}`">{{ statusLabel(student.status) }}</text> <text class="student__status" :class="`student__status--${student.status.toLowerCase()}`">{{ statusLabel(student.status) }}</text>
</view> </view>
<button v-if="student.phone" class="student__contact" :aria-label="`联系${student.nickname || '学员'}`" @tap="contactStudent(student.phone)"> <button v-if="student.phone" class="student__contact" :aria-label="`联系${student.nickname || '学员'}`" @tap.stop="contactStudent(student.phone)">
<text>{{ formatPhone(student.phone) }}</text><text class="student__contact-label">联系 </text> <text>{{ formatPhone(student.phone) }}</text><text class="student__contact-label">联系 </text>
</button> </button>
<text v-else class="student__no-phone">未留手机号</text> <text v-else class="student__no-phone">未留手机号</text>
@@ -175,6 +176,9 @@ function formatPhone(phone: string) {
function contactStudent(phone: string) { function contactStudent(phone: string) {
uni.makePhoneCall({ phoneNumber: phone }) uni.makePhoneCall({ phoneNumber: phone })
} }
function openSlot(slotId: string) {
uni.navigateTo({ url: `/pages/booking/detail?slotId=${encodeURIComponent(slotId)}` })
}
const STATUS_LABELS: Record<BookingStatus, string> = { const STATUS_LABELS: Record<BookingStatus, string> = {
[BookingStatus.PENDING_CONFIRMATION]: '待确认', [BookingStatus.PENDING_CONFIRMATION]: '待确认',
[BookingStatus.CONFIRMED]: '已确认', [BookingStatus.CONFIRMED]: '已确认',
@@ -219,6 +223,7 @@ button { margin: 0; padding: 0; background: transparent; font-weight: 400; borde
.schedule-scroll { flex: 1; min-height: 0; height: 0; } .schedule-scroll { flex: 1; min-height: 0; height: 0; }
.agenda { padding: 0 32rpx calc(40rpx + env(safe-area-inset-bottom)); } .agenda { padding: 0 32rpx calc(40rpx + env(safe-area-inset-bottom)); }
.session { margin-bottom: 24rpx; padding: 0 28rpx; overflow: hidden; background: #fff; border: 1rpx solid #deded5; border-radius: 20rpx; } .session { margin-bottom: 24rpx; padding: 0 28rpx; overflow: hidden; background: #fff; border: 1rpx solid #deded5; border-radius: 20rpx; }
.session--hover { background: #f4f1ec; }
.session__time { display: flex; align-items: baseline; gap: 20rpx; margin: 0 -28rpx; padding: 24rpx 28rpx; background: #eef2ed; border-bottom: 1rpx solid #dde4da; } .session__time { display: flex; align-items: baseline; gap: 20rpx; margin: 0 -28rpx; padding: 24rpx 28rpx; background: #eef2ed; border-bottom: 1rpx solid #dde4da; }
.session__start { display: block; font-size: 36rpx; font-variant-numeric: tabular-nums; font-weight: 500; } .session__start { display: block; font-size: 36rpx; font-variant-numeric: tabular-nums; font-weight: 500; }
.session__end { font-size: 24rpx; color: #687367; } .session__end { font-size: 24rpx; color: #687367; }
@@ -232,6 +237,8 @@ button { margin: 0; padding: 0; background: transparent; font-weight: 400; borde
.student__status--confirmed { background: #edf3ed; color: #526e62; } .student__status--confirmed { background: #edf3ed; color: #526e62; }
.student__status--pending_confirmation { background: #f8f0e3; color: #956b37; } .student__status--pending_confirmation { background: #f8f0e3; color: #956b37; }
.student__status--no_show { background: #f8eeea; color: #a06456; } .student__status--no_show { background: #f8eeea; color: #a06456; }
.student__status--completed { background: #e8e8e1; color: #5b6660; }
.student__status--cancelled { background: #efe5e0; color: #8c5d4f; text-decoration: line-through; }
.student__contact { width: 100%; min-height: 76rpx; line-height: 1.4; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8rpx; text-align: left; font-size: 23rpx; color: #81776f; font-variant-numeric: tabular-nums; } .student__contact { width: 100%; min-height: 76rpx; line-height: 1.4; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8rpx; text-align: left; font-size: 23rpx; color: #81776f; font-variant-numeric: tabular-nums; }
.student__contact-label { color: #526e62; font-size: 22rpx; } .student__contact-label { color: #526e62; font-size: 22rpx; }
.student__no-phone { display: block; padding: 18rpx 0; font-size: 23rpx; color: #81776f; } .student__no-phone { display: block; padding: 18rpx 0; font-size: 23rpx; color: #81776f; }

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
import { useInviteStore } from './invite'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import type { import type {
@@ -25,6 +26,13 @@ export const useUserStore = defineStore('user', () => {
const user = ref<UserProfileResponse | null>(null) const user = ref<UserProfileResponse | null>(null)
const stats = ref<UserStatsResponse | null>(null) const stats = ref<UserStatsResponse | null>(null)
const memberships = ref<readonly MembershipWithCardType[]>([]) const memberships = ref<readonly MembershipWithCardType[]>([])
const membershipsLoading = ref(false)
const membershipsLoaded = ref(false)
const membershipsError = ref(false)
const statsLoading = ref(false)
const statsError = ref(false)
let membershipRequestId = 0
let statsRequestId = 0
const token = ref<string>(uni.getStorageSync('token') as string || '') const token = ref<string>(uni.getStorageSync('token') as string || '')
// Getters // Getters
@@ -46,6 +54,7 @@ export const useUserStore = defineStore('user', () => {
const result = await wxLogin() const result = await wxLogin()
token.value = result.token token.value = result.token
user.value = result.user user.value = result.user
await useInviteStore().refresh().catch(() => {})
syncSubscriptionTemplates(result.user) syncSubscriptionTemplates(result.user)
return { user: result.user, isNewUser: result.isNewUser } return { user: result.user, isNewUser: result.isNewUser }
} catch (err) { } catch (err) {
@@ -72,6 +81,7 @@ export const useUserStore = defineStore('user', () => {
if (!isLoggedIn()) return if (!isLoggedIn()) return
try { try {
user.value = await get<UserProfileResponse>('/user/profile') user.value = await get<UserProfileResponse>('/user/profile')
await useInviteStore().refresh().catch(() => {})
syncSubscriptionTemplates(user.value) syncSubscriptionTemplates(user.value)
return user.value return user.value
} catch (err) { } catch (err) {
@@ -79,23 +89,42 @@ export const useUserStore = defineStore('user', () => {
} }
} }
async function fetchStats() { async function fetchStats(): Promise<boolean> {
if (!isLoggedIn()) return if (!isLoggedIn()) return false
const id = ++statsRequestId
const session = token.value
statsLoading.value = true
statsError.value = false
try { try {
stats.value = await get<UserStatsResponse>('/user/stats') const result = await get<UserStatsResponse>('/user/stats')
} catch (err) { if (id !== statsRequestId || session !== token.value) return false
console.error('Fetch stats failed:', err) stats.value = result
return true
} catch {
if (id === statsRequestId && session === token.value) statsError.value = true
return false
} finally {
if (id === statsRequestId && session === token.value) statsLoading.value = false
} }
} }
async function fetchMemberships(): Promise<boolean> { async function fetchMemberships(): Promise<boolean> {
if (!isLoggedIn()) return false if (!isLoggedIn()) return false
const id = ++membershipRequestId
const session = token.value
membershipsLoading.value = true
membershipsError.value = false
try { try {
memberships.value = await get<MembershipWithCardType[]>('/membership/my') const result = await get<MembershipWithCardType[]>('/membership/my')
if (id !== membershipRequestId || session !== token.value) return false
memberships.value = [...result]
membershipsLoaded.value = true
return true return true
} catch (err) { } catch {
console.error('Fetch memberships failed:', err) if (id === membershipRequestId && session === token.value) membershipsError.value = true
return false return false
} finally {
if (id === membershipRequestId && session === token.value) membershipsLoading.value = false
} }
} }
@@ -119,7 +148,15 @@ export const useUserStore = defineStore('user', () => {
} }
function clearSession() { function clearSession() {
membershipRequestId++
statsRequestId++
membershipsLoading.value = false
membershipsLoaded.value = false
membershipsError.value = false
statsLoading.value = false
statsError.value = false
token.value = '' token.value = ''
useInviteStore().reset()
user.value = null user.value = null
stats.value = null stats.value = null
memberships.value = [] memberships.value = []
@@ -134,6 +171,11 @@ export const useUserStore = defineStore('user', () => {
setUnauthorizedHandler(clearSession) setUnauthorizedHandler(clearSession)
return { return {
membershipsLoading,
membershipsLoaded,
membershipsError,
statsLoading,
statsError,
user, user,
stats, stats,
memberships, memberships,

View File

@@ -1,5 +1,4 @@
import { CardTypeCategory } from '@mp-pilates/shared' import { CardTypeCategory } from '@mp-pilates/shared'
import { FlashSalePhase } from '@mp-pilates/shared'
/** Minimal membership shape needed by progress/usage helpers. */ /** Minimal membership shape needed by progress/usage helpers. */
interface MembershipLike { interface MembershipLike {
@@ -13,6 +12,13 @@ export function formatPrice(cents: number): string {
return (cents / 100).toFixed(2) return (cents / 100).toFixed(2)
} }
/** 中国自然日 YYYY-MM-DD用于时间戳与 UTC 日期列展示 */
export function formatChinaDate(value: Date | string): string {
const time = new Date(value).getTime()
if (!Number.isFinite(time)) return ''
return new Date(time + 8 * 3600_000).toISOString().slice(0, 10)
}
/** 格式化日期为 YYYY-MM-DD */ /** 格式化日期为 YYYY-MM-DD */
export function formatDate(date: Date | string): string { export function formatDate(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date const d = typeof date === 'string' ? new Date(date) : date
@@ -121,17 +127,6 @@ export function getCountdownParts(targetTime: string): { readonly h: string; rea
} }
} }
/** 秒杀阶段中文标签 */
export function getFlashSalePhaseLabel(phase: FlashSalePhase): string {
const map: Record<FlashSalePhase, string> = {
[FlashSalePhase.UPCOMING]: '即将开始',
[FlashSalePhase.ONGOING]: '抢购中',
[FlashSalePhase.SOLD_OUT]: '已售罄',
[FlashSalePhase.ENDED]: '已结束',
}
return map[phase]
}
/** 库存已售比例 */ /** 库存已售比例 */
export function getStockRatio(soldCount: number, totalStock: number): number { export function getStockRatio(soldCount: number, totalStock: number): number {
if (totalStock === 0) return 0 if (totalStock === 0) return 0

View File

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

View File

@@ -21,6 +21,9 @@ API_BASE_URL=https://focus.richarjiang.com/
PORT=3000 PORT=3000
WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED=antYfc85gvwImFZ9kM4UiqMOywJxbqFVgKHLH3NikII WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED=antYfc85gvwImFZ9kM4UiqMOywJxbqFVgKHLH3NikII
WX_SUBSCRIBE_TEMPLATE_BOOKING_CANCELLED=5YX4ml6XwC0NegITxqsUXBPJzwKeOiM4VRZCaTiCgFM
WX_SUBSCRIBE_TEMPLATE_CLASS_REMINDER=CHwcRtAu-Bo6kYt-cmHMhBI4gDZdfoTarYqZotWQEi0
WX_SUBSCRIBE_TEMPLATE_CLASS_REVIEW=QJaTOSq_QpyL_spdNRTUfbkmeWDDi5iDAYZyXrFAPc8
# COS upload # COS upload
COS_SECRET_ID=AKIDwwulT3ub9f9bxFVdihcP4Z1S6qivMxmu COS_SECRET_ID=AKIDwwulT3ub9f9bxFVdihcP4Z1S6qivMxmu

View File

@@ -11,3 +11,9 @@ COS_REGION=ap-guangzhou
COS_PUBLIC_BASE_URL=https://plates-1251306435.cos.ap-guangzhou.myqcloud.com COS_PUBLIC_BASE_URL=https://plates-1251306435.cos.ap-guangzhou.myqcloud.com
COS_UPLOAD_PREFIX=mp/studio COS_UPLOAD_PREFIX=mp/studio
COS_UPLOAD_DURATION_SECONDS=1800 COS_UPLOAD_DURATION_SECONDS=1800
# WeChat subscribe message for class review reminders (24h after completion)
WX_SUBSCRIBE_TEMPLATE_CLASS_REVIEW=
WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED=
WX_SUBSCRIBE_TEMPLATE_BOOKING_CANCELLED=5YX4ml6XwC0NegITxqsUXBPJzwKeOiM4VRZCaTiCgFM
WX_SUBSCRIBE_TEMPLATE_CLASS_REMINDER=CHwcRtAu-Bo6kYt-cmHMhBI4gDZdfoTarYqZotWQEi0

View File

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

View File

@@ -0,0 +1,85 @@
-- AlterTable
ALTER TABLE `bookings` ADD COLUMN `review_reminder_claimed_at` DATETIME(3) NULL,
ADD COLUMN `review_reminder_due_at` DATETIME(3) NULL,
ADD COLUMN `review_reminder_sent_at` DATETIME(3) NULL;
-- CreateTable
CREATE TABLE `booking_reviews` (
`id` VARCHAR(191) NOT NULL,
`booking_id` VARCHAR(191) NOT NULL,
`rating` INTEGER NOT NULL,
`recommendation` INTEGER NULL,
`tags` JSON NOT NULL,
`comment` VARCHAR(200) NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `booking_reviews_booking_id_key`(`booking_id`),
INDEX `booking_reviews_created_at_idx`(`created_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `body_metrics` (
`id` VARCHAR(191) NOT NULL,
`user_id` VARCHAR(191) NOT NULL,
`recorded_at` DATE NOT NULL,
`weight` DOUBLE NULL,
`body_fat` DOUBLE NULL,
`waist` DOUBLE NULL,
`hip` DOUBLE NULL,
`flexibility` DOUBLE NULL,
`remark` VARCHAR(200) NOT NULL DEFAULT '',
`operator_id` VARCHAR(191) NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `body_metrics_user_id_recorded_at_idx`(`user_id`, `recorded_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `member_notes` (
`id` VARCHAR(191) NOT NULL,
`user_id` VARCHAR(191) NOT NULL,
`booking_id` VARCHAR(191) NULL,
`content` VARCHAR(1000) NOT NULL,
`shared` BOOLEAN NOT NULL DEFAULT false,
`operator_id` VARCHAR(191) NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `member_notes_user_id_created_at_idx`(`user_id`, `created_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `progress_photos` (
`id` VARCHAR(191) NOT NULL,
`user_id` VARCHAR(191) NOT NULL,
`object_key` VARCHAR(191) NOT NULL,
`caption` VARCHAR(200) NOT NULL DEFAULT '',
`recorded_at` DATE NOT NULL,
`uploaded_at` DATETIME(3) NULL,
`consented_at` DATETIME(3) NULL,
`revoked_at` DATETIME(3) NULL,
`operator_id` VARCHAR(191) NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `progress_photos_object_key_key`(`object_key`),
INDEX `progress_photos_user_id_recorded_at_idx`(`user_id`, `recorded_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `booking_reviews` ADD CONSTRAINT `booking_reviews_booking_id_fkey` FOREIGN KEY (`booking_id`) REFERENCES `bookings`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `body_metrics` ADD CONSTRAINT `body_metrics_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `member_notes` ADD CONSTRAINT `member_notes_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `member_notes` ADD CONSTRAINT `member_notes_booking_id_fkey` FOREIGN KEY (`booking_id`) REFERENCES `bookings`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `progress_photos` ADD CONSTRAINT `progress_photos_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,12 @@
-- Drop flash sale feature
-- See docs/flash-sale-removal.md for context
--
-- NOTE: production MySQL has no FK on orders.flash_sale_id, so we drop the
-- column directly without a prior DROP FOREIGN KEY.
-- Drop tables first
DROP TABLE IF EXISTS `flash_sale_orders`;
DROP TABLE IF EXISTS `flash_sales`;
-- Remove order.flash_sale_id column
ALTER TABLE `orders` DROP COLUMN `flash_sale_id`;

View File

@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE `bookings` ADD COLUMN `class_reminder_claimed_at` DATETIME(3) NULL,
ADD COLUMN `class_reminder_due_at` DATETIME(3) NULL,
ADD COLUMN `class_reminder_sent_at` DATETIME(3) NULL;

View File

@@ -51,18 +51,6 @@ enum OrderStatus {
REFUNDED REFUNDED
} }
enum FlashSaleStatus {
DRAFT
ACTIVE
ENDED
}
enum FlashSaleOrderStatus {
RESERVED
PAID
EXPIRED
}
enum InviteReferralStatus { enum InviteReferralStatus {
REGISTERED REGISTERED
TRIAL_PURCHASED TRIAL_PURCHASED
@@ -73,6 +61,7 @@ enum InviteReferralStatus {
model User { model User {
id String @id @default(uuid()) id String @id @default(uuid())
inviteCode String? @unique @map("invite_code") @db.VarChar(6)
openid String @unique openid String @unique
unionid String? unionid String?
phone String? phone String?
@@ -84,11 +73,13 @@ model User {
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
bodyMetrics BodyMetric[]
memberNotes MemberNote[]
progressPhotos ProgressPhoto[]
lessonSupplements LessonSupplement[] lessonSupplements LessonSupplement[]
memberships Membership[] memberships Membership[]
bookings Booking[] bookings Booking[]
orders Order[] orders Order[]
flashSaleOrders FlashSaleOrder[]
subscriptionMessageConsents SubscriptionMessageConsent[] subscriptionMessageConsents SubscriptionMessageConsent[]
sentInviteReferrals InviteReferral[] @relation("InviteReferralInviter") sentInviteReferrals InviteReferral[] @relation("InviteReferralInviter")
receivedInviteReferral InviteReferral[] @relation("InviteReferralInvitee") receivedInviteReferral InviteReferral[] @relation("InviteReferralInvitee")
@@ -139,7 +130,6 @@ model CardType {
memberships Membership[] memberships Membership[]
orders Order[] orders Order[]
flashSales FlashSale[]
@@map("card_types") @@map("card_types")
} }
@@ -225,6 +215,14 @@ model Booking {
membership Membership @relation(fields: [membershipId], references: [id]) membership Membership @relation(fields: [membershipId], references: [id])
qualifiedInviteReferrals InviteReferral[] qualifiedInviteReferrals InviteReferral[]
review BookingReview?
memberNotes MemberNote[]
reviewReminderDueAt DateTime? @map("review_reminder_due_at")
reviewReminderClaimedAt DateTime? @map("review_reminder_claimed_at")
reviewReminderSentAt DateTime? @map("review_reminder_sent_at")
classReminderDueAt DateTime? @map("class_reminder_due_at")
classReminderClaimedAt DateTime? @map("class_reminder_claimed_at")
classReminderSentAt DateTime? @map("class_reminder_sent_at")
statusHistory BookingStatusHistory[] statusHistory BookingStatusHistory[]
@@unique([userId, timeSlotId]) @@unique([userId, timeSlotId])
@@ -254,18 +252,18 @@ model Order {
cardTypeId String @map("card_type_id") cardTypeId String @map("card_type_id")
membershipId String? @map("membership_id") membershipId String? @map("membership_id")
orderNo String @unique @map("order_no") orderNo String @unique @map("order_no")
inviteInviterId String? @map("invite_inviter_id")
purchasedCategory String? @map("purchased_category")
amount Decimal @db.Decimal(10, 0) amount Decimal @db.Decimal(10, 0)
status OrderStatus @default(PENDING) status OrderStatus @default(PENDING)
wxTransactionId String? @map("wx_transaction_id") wxTransactionId String? @map("wx_transaction_id")
paidAt DateTime? @map("paid_at") paidAt DateTime? @map("paid_at")
flashSaleId String? @map("flash_sale_id")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
cardType CardType @relation(fields: [cardTypeId], references: [id]) cardType CardType @relation(fields: [cardTypeId], references: [id])
membership Membership? @relation(fields: [membershipId], references: [id]) membership Membership? @relation(fields: [membershipId], references: [id])
flashSaleOrder FlashSaleOrder?
inviteReferrals InviteReferral[] inviteReferrals InviteReferral[]
@@index([userId]) @@index([userId])
@@ -331,51 +329,6 @@ model StudioConfig {
@@map("studio_config") @@map("studio_config")
} }
model FlashSale {
id String @id @default(uuid())
cardTypeId String @map("card_type_id")
title String
originalPrice Decimal @map("original_price") @db.Decimal(10, 0)
flashPrice Decimal @map("flash_price") @db.Decimal(10, 0)
totalStock Int @map("total_stock")
soldCount Int @default(0) @map("sold_count")
startTime DateTime @map("start_time")
endTime DateTime @map("end_time")
status FlashSaleStatus @default(DRAFT)
description String? @db.Text
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
cardType CardType @relation(fields: [cardTypeId], references: [id])
orders FlashSaleOrder[]
@@index([status, startTime, endTime])
@@map("flash_sales")
}
model FlashSaleOrder {
id String @id @default(uuid())
flashSaleId String @map("flash_sale_id")
userId String @map("user_id")
orderId String? @unique @map("order_id")
status FlashSaleOrderStatus @default(RESERVED)
reservedAt DateTime @default(now()) @map("reserved_at")
paidAt DateTime? @map("paid_at")
expiredAt DateTime? @map("expired_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
flashSale FlashSale @relation(fields: [flashSaleId], references: [id])
user User @relation(fields: [userId], references: [id])
order Order? @relation(fields: [orderId], references: [id])
@@unique([flashSaleId, userId])
@@index([userId])
@@index([status])
@@map("flash_sale_orders")
}
// Historical totals without fabricated scheduled dates. // Historical totals without fabricated scheduled dates.
model LessonSupplement { model LessonSupplement {
id String @id @default(uuid()) id String @id @default(uuid())
@@ -399,3 +352,63 @@ model LessonSupplement {
@@index([userId, revokedAt, createdAt]) @@index([userId, revokedAt, createdAt])
@@map("lesson_supplements") @@map("lesson_supplements")
} }
model BookingReview {
id String @id @default(uuid())
bookingId String @unique @map("booking_id")
rating Int
recommendation Int?
tags Json
comment String @db.VarChar(200)
createdAt DateTime @default(now()) @map("created_at")
booking Booking @relation(fields: [bookingId], references: [id])
@@index([createdAt])
@@map("booking_reviews")
}
model BodyMetric {
id String @id @default(uuid())
userId String @map("user_id")
recordedAt DateTime @db.Date @map("recorded_at")
weight Float?
bodyFat Float? @map("body_fat")
waist Float?
hip Float?
flexibility Float?
remark String @default("") @db.VarChar(200)
operatorId String @map("operator_id")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id])
@@index([userId, recordedAt])
@@map("body_metrics")
}
model MemberNote {
id String @id @default(uuid())
userId String @map("user_id")
bookingId String? @map("booking_id")
content String @db.VarChar(1000)
shared Boolean @default(false)
operatorId String @map("operator_id")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id])
booking Booking? @relation(fields: [bookingId], references: [id])
@@index([userId, createdAt])
@@map("member_notes")
}
model ProgressPhoto {
id String @id @default(uuid())
userId String @map("user_id")
objectKey String @unique @map("object_key")
caption String @default("") @db.VarChar(200)
recordedAt DateTime @db.Date @map("recorded_at")
uploadedAt DateTime? @map("uploaded_at")
consentedAt DateTime? @map("consented_at")
revokedAt DateTime? @map("revoked_at")
operatorId String @map("operator_id")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id])
@@index([userId, recordedAt])
@@map("progress_photos")
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,58 @@
import { BadRequestException, Injectable } from '@nestjs/common'
import { BookingStatus, type TeachingAnalytics, type TeachingAnalyticsRecord, type TeachingAnalyticsSummary } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
@Injectable()
export class TeachingAnalyticsService {
constructor(private readonly prisma: PrismaService) {}
async getMonthly(month: string): Promise<TeachingAnalytics> {
if (typeof month !== 'string' || !/^(20\d{2})-(0[1-9]|1[0-2])$/.test(month)) {
throw new BadRequestException('月份格式应为 YYYY-MM范围为 2000—2099 年')
}
const [year, number] = month.split('-').map(Number)
const start = new Date(Date.UTC(year, number - 1, 1))
const previousStart = new Date(Date.UTC(year, number - 2, 1))
const end = new Date(Date.UTC(year, number, 1))
const now = new Date()
const bookings = await this.prisma.booking.findMany({
where: { timeSlot: { date: { gte: previousStart, lt: end } } },
select: {
id: true, userId: true, status: true,
user: { select: { nickname: true } },
timeSlot: { select: { id: true, date: true, startTime: true, endTime: true } },
membership: { select: { cardType: { select: { name: true } } } },
},
orderBy: [{ timeSlot: { date: 'asc' } }, { timeSlot: { startTime: 'asc' } }, { id: 'asc' }],
})
const rows: TeachingAnalyticsRecord[] = bookings.map((booking) => {
const slot = booking.timeSlot
const date = slot.date.toISOString().slice(0, 10)
const unfinished = booking.status === BookingStatus.CONFIRMED || booking.status === BookingStatus.PENDING_CONFIRMATION
return {
id: booking.id, userId: booking.userId, nickname: booking.user.nickname,
slotId: slot.id, date, startTime: slot.startTime, endTime: slot.endTime,
cardName: booking.membership.cardType.name, status: booking.status as BookingStatus,
needsReview: unfinished && new Date(`${date}T${slot.endTime}:00+08:00`).getTime() < now.getTime(),
}
})
const records = rows.filter((row) => row.date >= start.toISOString().slice(0, 10))
return {
month, generatedAt: now.toISOString(), records,
summary: this.summarize(records), previousMonth: previousStart.toISOString().slice(0, 7),
previous: this.summarize(rows.filter((row) => row.date < start.toISOString().slice(0, 10))),
}
}
private summarize(rows: TeachingAnalyticsRecord[]): TeachingAnalyticsSummary {
const completed = rows.filter((row) => row.status === BookingStatus.COMPLETED)
const slots = new Map(completed.map((row) => [row.slotId, row]))
const minutes = [...slots.values()].reduce((total, slot) => {
const parse = (time: string): number => Number(time.slice(0, 2)) * 60 + Number(time.slice(3, 5))
return total + Math.max(0, parse(slot.endTime) - parse(slot.startTime))
}, 0)
return { sessions: slots.size, attendances: completed.length,
students: new Set(completed.map((row) => row.userId)).size,
teachingDays: new Set(completed.map((row) => row.date)).size, minutes }
}
}

View File

@@ -11,7 +11,6 @@ import { BookingModule } from './booking/booking.module'
import { SchedulerModule } from './scheduler/scheduler.module' import { SchedulerModule } from './scheduler/scheduler.module'
import { PaymentModule } from './payment/payment.module' import { PaymentModule } from './payment/payment.module'
import { AdminModule } from './admin/admin.module' import { AdminModule } from './admin/admin.module'
import { FlashSaleModule } from './flash-sale/flash-sale.module'
import { InviteModule } from './invite/invite.module' import { InviteModule } from './invite/invite.module'
@Module({ @Module({
@@ -30,7 +29,6 @@ import { InviteModule } from './invite/invite.module'
SchedulerModule, SchedulerModule,
PaymentModule, PaymentModule,
AdminModule, AdminModule,
FlashSaleModule,
InviteModule, InviteModule,
], ],
controllers: [AppController], controllers: [AppController],

View File

@@ -84,7 +84,7 @@ describe('AuthService', () => {
jest.clearAllMocks() jest.clearAllMocks()
mockJwtService.sign.mockReturnValue(JWT_TOKEN) mockJwtService.sign.mockReturnValue(JWT_TOKEN)
mockPrismaService.membership.count.mockResolvedValue(0) mockPrismaService.membership.count.mockResolvedValue(0)
mockConfigService.get.mockReturnValue('tmpl-booking-confirmed') mockConfigService.get.mockImplementation((key: string) => key === 'WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED' ? 'tmpl-booking-confirmed' : '')
}) })
// ── login ────────────────────────────────────────────────────────────────── // ── login ──────────────────────────────────────────────────────────────────

View File

@@ -71,6 +71,7 @@ export class AuthService {
private buildSubscriptionTemplateConfig(): SubscriptionMessageTemplateConfig { private buildSubscriptionTemplateConfig(): SubscriptionMessageTemplateConfig {
const templates = [ const templates = [
{ templateId: this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_CLASS_REVIEW', ''), scene: SubscriptionMessageScene.CLASS_REVIEW, description: '课程完成 24 小时后提醒评价', usageTarget: 'consent' as const },
{ {
templateId: this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED', ''), templateId: this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED', ''),
scene: SubscriptionMessageScene.BOOKING_CREATED, scene: SubscriptionMessageScene.BOOKING_CREATED,

View File

@@ -20,6 +20,10 @@ const MOCK_SLOT_ID = 'slot-001'
const MOCK_MEMBERSHIP_ID = 'mem-001' const MOCK_MEMBERSHIP_ID = 'mem-001'
const MOCK_BOOKING_ID = 'booking-001' const MOCK_BOOKING_ID = 'booking-001'
const MEMBER_ACTOR = { id: MOCK_USER_ID, isAdmin: false }
const MOCK_ADMIN_ID = 'admin-001'
const ADMIN_ACTOR = { id: MOCK_ADMIN_ID, isAdmin: true }
const mockTimesCardType = { const mockTimesCardType = {
id: 'ct-times-001', id: 'ct-times-001',
name: '10次卡', name: '10次卡',
@@ -166,7 +170,12 @@ describe('BookingService', () => {
let service: BookingService let service: BookingService
let prisma: jest.Mocked<PrismaService> let prisma: jest.Mocked<PrismaService>
let studioService: jest.Mocked<StudioService> let studioService: jest.Mocked<StudioService>
let subscriptionMessageService: { sendBookingConfirmedMessage: jest.Mock; sendAdminBookingCreatedMessage: jest.Mock } let subscriptionMessageService: {
sendBookingConfirmedMessage: jest.Mock
sendAdminBookingCreatedMessage: jest.Mock
sendBookingCancelledMessage: jest.Mock
sendClassReminderMessage: jest.Mock
}
let inviteService: { recordQualifiedTrialBooking: jest.Mock } let inviteService: { recordQualifiedTrialBooking: jest.Mock }
beforeEach(async () => { beforeEach(async () => {
@@ -183,6 +192,7 @@ describe('BookingService', () => {
count: jest.fn(), count: jest.fn(),
create: jest.fn(), create: jest.fn(),
update: jest.fn(), update: jest.fn(),
groupBy: jest.fn(),
}, },
timeSlot: { timeSlot: {
findUnique: jest.fn(), findUnique: jest.fn(),
@@ -218,6 +228,8 @@ describe('BookingService', () => {
useValue: { useValue: {
sendBookingConfirmedMessage: jest.fn(), sendBookingConfirmedMessage: jest.fn(),
sendAdminBookingCreatedMessage: jest.fn(), sendAdminBookingCreatedMessage: jest.fn(),
sendBookingCancelledMessage: jest.fn(),
sendClassReminderMessage: jest.fn(),
}, },
}, },
{ {
@@ -408,6 +420,12 @@ describe('BookingService', () => {
await service.completeBooking(MOCK_BOOKING_ID, 'admin-001') await service.completeBooking(MOCK_BOOKING_ID, 'admin-001')
expect(tx.booking.update).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
status: BookingStatus.COMPLETED,
reviewReminderDueAt: expect.any(Date),
}),
}))
expect(inviteService.recordQualifiedTrialBooking).toHaveBeenCalledWith(MOCK_BOOKING_ID) expect(inviteService.recordQualifiedTrialBooking).toHaveBeenCalledWith(MOCK_BOOKING_ID)
}) })
}) })
@@ -756,7 +774,7 @@ describe('BookingService', () => {
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
const result = await service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID) const result = await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)
expect(tx.booking.update).toHaveBeenCalledWith( expect(tx.booking.update).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@@ -807,7 +825,7 @@ describe('BookingService', () => {
}) })
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
const result = await service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID) const result = await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)
expect(tx.membership.update).toHaveBeenCalledWith( expect(tx.membership.update).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@@ -840,7 +858,7 @@ describe('BookingService', () => {
tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 }) tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 })
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
const result = await service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID) const result = await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)
expect(tx.membership.update).not.toHaveBeenCalled() expect(tx.membership.update).not.toHaveBeenCalled()
expect(result.refunded).toBe(false) expect(result.refunded).toBe(false)
@@ -869,7 +887,7 @@ describe('BookingService', () => {
tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 }) tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 })
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
const result = await service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID) const result = await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)
expect(tx.membership.update).not.toHaveBeenCalled() expect(tx.membership.update).not.toHaveBeenCalled()
expect(result.refunded).toBe(false) expect(result.refunded).toBe(false)
@@ -891,7 +909,7 @@ describe('BookingService', () => {
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
const result = await service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID) const result = await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)
expect(result.refunded).toBe(false) expect(result.refunded).toBe(false)
// membership.update must NOT be called // membership.update must NOT be called
@@ -914,7 +932,7 @@ describe('BookingService', () => {
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID) await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)
// slot was FULL → should be restored to OPEN // slot was FULL → should be restored to OPEN
expect(tx.timeSlot.update).toHaveBeenCalledWith( expect(tx.timeSlot.update).toHaveBeenCalledWith(
@@ -949,7 +967,7 @@ describe('BookingService', () => {
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
const result = await service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID) const result = await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)
expect(tx.membership.update).toHaveBeenCalledWith( expect(tx.membership.update).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@@ -965,7 +983,7 @@ describe('BookingService', () => {
it('throws NotFoundException when booking does not exist', async () => { it('throws NotFoundException when booking does not exist', async () => {
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(null) ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(null)
await expect(service.cancelBooking(MOCK_USER_ID, 'nonexistent')).rejects.toThrow( await expect(service.cancelBooking(MEMBER_ACTOR, 'nonexistent')).rejects.toThrow(
NotFoundException, NotFoundException,
) )
}) })
@@ -974,7 +992,7 @@ describe('BookingService', () => {
const otherBooking = { ...mockConfirmedBooking, userId: 'other-user', timeSlot: futureSlot, membership: mockActiveMembership } const otherBooking = { ...mockConfirmedBooking, userId: 'other-user', timeSlot: futureSlot, membership: mockActiveMembership }
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(otherBooking) ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(otherBooking)
await expect(service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID)).rejects.toThrow( await expect(service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)).rejects.toThrow(
ForbiddenException, ForbiddenException,
) )
}) })
@@ -988,10 +1006,119 @@ describe('BookingService', () => {
} }
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(cancelledBooking) ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(cancelledBooking)
await expect(service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID)).rejects.toThrow( await expect(service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)).rejects.toThrow(
BadRequestException, BadRequestException,
) )
}) })
// ─── Admin actor branch ─────────────────────────────────────────────────
it('admin actor can cancel another user\'s booking (skips owner check)', async () => {
const otherUserBooking = {
...mockConfirmedBooking,
userId: 'other-user',
timeSlot: { ...futureSlot, bookedCount: 1 },
membership: mockActiveMembership,
}
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(otherUserBooking)
const tx = buildTxMock()
tx.booking.update.mockResolvedValue({ ...otherUserBooking, status: BookingStatus.CANCELLED, cancelledAt: new Date() })
tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 })
tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 6 })
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
const result = await service.cancelBooking(ADMIN_ACTOR, MOCK_BOOKING_ID)
expect(result.refunded).toBe(true)
expect(tx.booking.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ status: BookingStatus.CANCELLED }),
}),
)
})
it('admin actor records 管理员 remark in bookingStatusHistory', async () => {
const otherUserBooking = {
...mockConfirmedBooking,
userId: 'other-user',
timeSlot: { ...futureSlot, bookedCount: 1 },
membership: mockActiveMembership,
}
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(otherUserBooking)
const tx = buildTxMock()
tx.booking.update.mockResolvedValue({ ...otherUserBooking, status: BookingStatus.CANCELLED, cancelledAt: new Date() })
tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 })
tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 6 })
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await service.cancelBooking(ADMIN_ACTOR, MOCK_BOOKING_ID)
expect(tx.bookingStatusHistory.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
operatorId: MOCK_ADMIN_ID,
remark: '管理员取消预约(超时退款)',
}),
}),
)
})
it('member actor records 学员 remark when cancelling own booking', async () => {
const ownBooking = {
...mockConfirmedBooking,
timeSlot: { ...futureSlot, bookedCount: 1 },
membership: mockActiveMembership,
}
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(ownBooking)
const tx = buildTxMock()
tx.booking.update.mockResolvedValue({ ...ownBooking, status: BookingStatus.CANCELLED, cancelledAt: new Date() })
tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 })
tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 6 })
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)
expect(tx.bookingStatusHistory.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
operatorId: MOCK_USER_ID,
remark: '学员取消预约(超时退款)',
}),
}),
)
})
it('triggers sendBookingCancelledMessage when a booking is cancelled', async () => {
const futureDate = new Date(Date.now() + 86400000 * 2)
const futureSlot = { ...mockOpenSlot, date: futureDate, startTime: '14:00', endTime: '15:00' }
const ownBooking = {
...mockConfirmedBooking,
userId: MOCK_USER_ID,
timeSlot: futureSlot,
membership: mockActiveMembership,
}
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(ownBooking)
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ id: MOCK_USER_ID, openid: 'test-user-openid' } as any)
const tx = buildTxMock()
tx.booking.update.mockResolvedValue({ ...ownBooking, status: BookingStatus.CANCELLED, cancelledAt: new Date() })
tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 })
tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 6 })
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)
expect(subscriptionMessageService.sendBookingCancelledMessage).toHaveBeenCalledWith(
expect.objectContaining({
openid: 'test-user-openid',
userId: MOCK_USER_ID,
bookingId: MOCK_BOOKING_ID,
courseName: 'Test Studio',
}),
)
})
}) })
// ─── getMyBookings ──────────────────────────────────────────────────────── // ─── getMyBookings ────────────────────────────────────────────────────────
@@ -1080,8 +1207,10 @@ describe('BookingService', () => {
membership: mockActiveMembership, membership: mockActiveMembership,
}, },
] ]
;(prisma.booking.groupBy as jest.Mock).mockResolvedValue([
{ status: BookingStatus.CONFIRMED, _count: { _all: 1 } },
])
;(prisma.booking.findMany as jest.Mock).mockResolvedValue(bookings) ;(prisma.booking.findMany as jest.Mock).mockResolvedValue(bookings)
;(prisma.booking.count as jest.Mock).mockResolvedValue(1)
const result = await service.getAllBookings(1, 10) const result = await service.getAllBookings(1, 10)
@@ -1101,6 +1230,45 @@ describe('BookingService', () => {
}), }),
) )
}) })
it('sorts the unfiltered list by status priority and pages across segments', async () => {
// 全部视图:待确认 → 已确认 → 已完成 → 已取消,未到归入已完成之后
;(prisma.booking.groupBy as jest.Mock).mockResolvedValue([
{ status: BookingStatus.PENDING_CONFIRMATION, _count: { _all: 1 } },
{ status: BookingStatus.CONFIRMED, _count: { _all: 2 } },
{ status: BookingStatus.COMPLETED, _count: { _all: 1 } },
{ status: BookingStatus.CANCELLED, _count: { _all: 1 } },
])
const byStatus: Record<string, { id: string }[]> = {
[BookingStatus.PENDING_CONFIRMATION]: [{ id: 'b-pending' }],
[BookingStatus.CONFIRMED]: [{ id: 'b-confirmed-1' }, { id: 'b-confirmed-2' }],
[BookingStatus.COMPLETED]: [{ id: 'b-completed' }],
[BookingStatus.CANCELLED]: [{ id: 'b-cancelled' }],
}
;(prisma.booking.findMany as jest.Mock).mockImplementation(
(args: { where: { status: string } }) => byStatus[args.where.status] ?? [],
)
const page1 = await service.getAllBookings(1, 3)
expect(page1.total).toBe(5)
expect(page1.data.map((b) => b.id)).toEqual(['b-pending', 'b-confirmed-1', 'b-confirmed-2'])
const page2 = await service.getAllBookings(2, 3)
expect(page2.data.map((b) => b.id)).toEqual(['b-completed', 'b-cancelled'])
// 段内排序:已确认按上课时间正序,其余按创建时间倒序
const confirmedCall = (prisma.booking.findMany as jest.Mock).mock.calls.find(
([args]: [{ where: { status: string } }]) => args.where.status === BookingStatus.CONFIRMED,
)
expect(confirmedCall[0].orderBy).toEqual([
{ timeSlot: { date: 'asc' } },
{ timeSlot: { startTime: 'asc' } },
])
const pendingCall = (prisma.booking.findMany as jest.Mock).mock.calls.find(
([args]: [{ where: { status: string } }]) => args.where.status === BookingStatus.PENDING_CONFIRMATION,
)
expect(pendingCall[0].orderBy).toEqual({ createdAt: 'desc' })
})
}) })
describe('getTeachingScheduleByDate', () => { describe('getTeachingScheduleByDate', () => {
@@ -1138,7 +1306,7 @@ describe('BookingService', () => {
}, },
]) ])
const result = await service.getTeachingScheduleByDate('2026-04-19') const result = await service.getTeachingScheduleByDate('2099-12-31')
expect(prisma.timeSlot.findMany).toHaveBeenCalledWith( expect(prisma.timeSlot.findMany).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@@ -1158,7 +1326,7 @@ describe('BookingService', () => {
expect(result).toEqual([ expect(result).toEqual([
{ {
slotId: 'slot-01', slotId: 'slot-01',
date: '2026-04-19', date: '2099-12-31',
startTime: '09:00', startTime: '09:00',
endTime: '10:00', endTime: '10:00',
bookedCount: 2, bookedCount: 2,
@@ -1175,7 +1343,7 @@ describe('BookingService', () => {
}, },
{ {
slotId: 'slot-02', slotId: 'slot-02',
date: '2026-04-19', date: '2099-12-31',
startTime: '11:00', startTime: '11:00',
endTime: '12:00', endTime: '12:00',
bookedCount: 1, bookedCount: 1,
@@ -1193,6 +1361,98 @@ describe('BookingService', () => {
]) ])
}) })
it('returns all-status bookings when the date is today', async () => {
const now = new Date()
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
;(prisma.timeSlot.findMany as jest.Mock).mockResolvedValue([
{
id: 'slot-today',
startTime: '10:00',
endTime: '11:00',
bookedCount: 2,
capacity: 3,
bookings: [
{
id: 'booking-completed',
status: BookingStatus.COMPLETED,
createdAt: new Date(`${today}T00:00:00Z`),
user: { id: 'user-1', nickname: '完成', phone: '13800000001' },
},
{
id: 'booking-no-show',
status: BookingStatus.NO_SHOW,
createdAt: new Date(`${today}T00:00:01Z`),
user: { id: 'user-2', nickname: '未到', phone: null },
},
{
id: 'booking-cancelled',
status: BookingStatus.CANCELLED,
createdAt: new Date(`${today}T00:00:02Z`),
user: { id: 'user-3', nickname: '取消', phone: '13800000003' },
},
{
id: 'booking-confirmed',
status: BookingStatus.CONFIRMED,
createdAt: new Date(`${today}T00:00:03Z`),
user: { id: 'user-4', nickname: '确认', phone: null },
},
],
},
])
const result = await service.getTeachingScheduleByDate(today)
// 当日课表timeSlot 仍用 EXISTS 守卫过滤完全没人预约的空 slot不带状态条件
// 且 included bookings 不再限制状态。
const callArg = (prisma.timeSlot.findMany as jest.Mock).mock.calls[0][0]
expect(callArg.where).toEqual({
date: expect.any(Date),
bookings: { some: {} },
})
expect(callArg.include.bookings.where).toBeUndefined()
expect(result).toEqual([
{
slotId: 'slot-today',
date: today,
startTime: '10:00',
endTime: '11:00',
bookedCount: 2,
capacity: 3,
students: [
{
bookingId: 'booking-completed',
userId: 'user-1',
nickname: '完成',
phone: '13800000001',
status: BookingStatus.COMPLETED,
},
{
bookingId: 'booking-no-show',
userId: 'user-2',
nickname: '未到',
phone: null,
status: BookingStatus.NO_SHOW,
},
{
bookingId: 'booking-cancelled',
userId: 'user-3',
nickname: '取消',
phone: '13800000003',
status: BookingStatus.CANCELLED,
},
{
bookingId: 'booking-confirmed',
userId: 'user-4',
nickname: '确认',
phone: null,
status: BookingStatus.CONFIRMED,
},
],
},
])
})
it('rejects invalid date input', async () => { it('rejects invalid date input', async () => {
await expect(service.getTeachingScheduleByDate('invalid-date')).rejects.toThrow( await expect(service.getTeachingScheduleByDate('invalid-date')).rejects.toThrow(
BadRequestException, BadRequestException,

View File

@@ -0,0 +1,69 @@
import { ReviewService, summarizeReviews } from '../review.service'
import { PrismaService } from '../../prisma/prisma.service'
import { Prisma } from '@prisma/client'
import { CreateReviewDto } from '../dto/create-review.dto'
import { validate } from 'class-validator'
import 'reflect-metadata'
import { Reflector } from '@nestjs/core'
import { ExecutionContext } from '@nestjs/common'
import { GUARDS_METADATA } from '@nestjs/common/constants'
import { ReviewController, PublicReviewController } from '../review.controller'
import { JwtAuthGuard } from '../../auth/jwt-auth.guard'
import { RolesGuard } from '../../auth/roles.guard'
describe('Class reviews', () => {
const db = { booking: { findFirst: jest.fn() }, bookingReview: { create: jest.fn(), findMany: jest.fn(), count: jest.fn() } }
let service: ReviewService
beforeEach(() => { jest.resetAllMocks(); service = new ReviewService(db as unknown as PrismaService) })
const dto = { rating: 5, tags: ['氛围好'], comment: '很有收获' }
it('does not expose another member booking or review', async () => {
db.booking.findFirst.mockResolvedValue(null)
await expect(service.get('other', 'booking')).rejects.toThrow('预约不存在')
expect(db.booking.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 'booking', userId: 'other' } }))
await expect(service.create('other', 'booking', dto)).rejects.toThrow('预约不存在')
expect(db.bookingReview.create).not.toHaveBeenCalled()
})
it.each(['CONFIRMED', 'NO_SHOW', 'CANCELLED', 'PENDING_CONFIRMATION'])('rejects %s booking', async status => {
db.booking.findFirst.mockResolvedValue({ status })
await expect(service.create('member', 'booking', dto)).rejects.toThrow('完成课程后才能评价')
})
it('allows older completed lessons, with database uniqueness protecting double submits', async () => {
db.booking.findFirst.mockResolvedValue({ status: 'COMPLETED', completedAt: new Date('2020-01-01') })
db.bookingReview.create.mockResolvedValueOnce({ id: 'review' }).mockRejectedValueOnce(new Prisma.PrismaClientKnownRequestError('duplicate', { code: 'P2002', clientVersion: '5' }))
await expect(service.create('member', 'booking', dto)).resolves.toEqual({ id: 'review' })
await expect(service.create('member', 'booking', dto)).rejects.toThrow('已经评价')
})
it.each([{ rating: 0 }, { rating: 6 }, { rating: 2.5 }, { tags: ['伪造标签'] }, { tags: ['氛围好', '氛围好'] }, { tags: ['动作到位','氛围好','强度合适','讲解清晰'] }, { recommendation: 11 }, { recommendation: -1 }, { comment: '长'.repeat(201) }])('rejects invalid payload %j', async patch => {
expect((await validate(Object.assign(new CreateReviewDto(), dto, patch))).length).toBeGreaterThan(0)
})
it('accepts optional recommendation including zero', async () => {
expect(await validate(Object.assign(new CreateReviewDto(), dto, { recommendation: 0 }))).toHaveLength(0)
})
it('keeps star average separate from NPS and handles zero samples', () => {
expect(summarizeReviews([])).toEqual({ count: 0, average: null, nps: null, npsCount: 0 })
expect(summarizeReviews([{ rating: 5, recommendation: 0 }, { rating: 1, recommendation: 9 }, { rating: 4, recommendation: 7 }, { rating: 4, recommendation: null }])).toEqual({ count: 4, average: 3.5, nps: 0, npsCount: 3 })
})
it('groups the six Chinese calendar months across year and UTC boundaries', async () => {
db.bookingReview.findMany.mockResolvedValue([{ rating: 5, recommendation: 10, createdAt: new Date('2025-12-31T16:00:00Z') }, { rating: 1, recommendation: 0, createdAt: new Date('2025-12-31T15:59:59Z') }])
const result = await service.trend('2026-01')
expect(result.map(r => r.month)).toEqual(['2025-08','2025-09','2025-10','2025-11','2025-12','2026-01'])
expect(result[4].average).toBe(1); expect(result[5].average).toBe(5)
expect(db.bookingReview.findMany.mock.calls[0][0].where.createdAt).toEqual({ gte: new Date('2025-07-31T16:00:00Z'), lt: new Date('2026-01-31T16:00:00Z') })
})
})
describe('Review authorization', () => {
it('requires authentication for member and admin review endpoints', () => {
expect(Reflect.getMetadata(GUARDS_METADATA, ReviewController)).toContain(JwtAuthGuard)
})
it('keeps the public summary unauthenticated', () => {
expect(Reflect.getMetadata(GUARDS_METADATA, PublicReviewController) || []).not.toContain(JwtAuthGuard)
})
it.each(['list', 'trend'] as const)('restricts %s to admins', method => {
const handler = ReviewController.prototype[method]
expect(Reflect.getMetadata(GUARDS_METADATA, handler)).toContain(RolesGuard)
const guard = new RolesGuard(new Reflector())
const context = (role: string) => ({ getHandler: () => handler, getClass: () => ReviewController, switchToHttp: () => ({ getRequest: () => ({ user: { role } }) }) }) as unknown as ExecutionContext
expect(guard.canActivate(context('MEMBER'))).toBe(false)
expect(guard.canActivate(context('ADMIN'))).toBe(true)
})
})

View File

@@ -14,6 +14,7 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard'
import { RolesGuard } from '../auth/roles.guard' import { RolesGuard } from '../auth/roles.guard'
import { Roles } from '../auth/roles.decorator' import { Roles } from '../auth/roles.decorator'
import { CurrentUser } from '../common/decorators/current-user.decorator' import { CurrentUser } from '../common/decorators/current-user.decorator'
import { AuthenticatedUser } from '../auth/jwt.strategy'
import { BookingService } from './booking.service' import { BookingService } from './booking.service'
import { CreateBookingDto } from './dto/create-booking.dto' import { CreateBookingDto } from './dto/create-booking.dto'
import { AdminArrangeBookingDto } from './dto/admin-arrange-booking.dto' import { AdminArrangeBookingDto } from './dto/admin-arrange-booking.dto'
@@ -36,10 +37,13 @@ export class BookingController {
@Put('booking/:id/cancel') @Put('booking/:id/cancel')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
async cancelBooking( async cancelBooking(
@CurrentUser('sub') userId: string, @CurrentUser() user: AuthenticatedUser,
@Param('id') id: string, @Param('id') id: string,
) { ) {
return this.bookingService.cancelBooking(userId, id) return this.bookingService.cancelBooking(
{ id: user.sub, isAdmin: user.role === UserRole.ADMIN },
id,
)
} }
@Get('booking/my/activity') @Get('booking/my/activity')

View File

@@ -1,3 +1,5 @@
import { ReviewController, PublicReviewController } from './review.controller'
import { ReviewService } from './review.service'
import { Module } from '@nestjs/common' import { Module } from '@nestjs/common'
import { BookingController } from './booking.controller' import { BookingController } from './booking.controller'
import { BookingService } from './booking.service' import { BookingService } from './booking.service'
@@ -8,8 +10,8 @@ import { InviteModule } from '../invite/invite.module'
@Module({ @Module({
imports: [MembershipModule, StudioModule, UserModule, InviteModule], imports: [MembershipModule, StudioModule, UserModule, InviteModule],
controllers: [BookingController], controllers: [BookingController, ReviewController, PublicReviewController],
providers: [BookingService], providers: [BookingService, ReviewService],
exports: [BookingService], exports: [BookingService],
}) })
export class BookingModule {} export class BookingModule {}

View File

@@ -41,6 +41,10 @@ export interface CancelBookingResult {
refunded: boolean refunded: boolean
} }
export type AdminBookingRow = BookingWithRelations & {
user: { id: string; nickname: string; phone: string | null }
}
// ─── Helpers ──────────────────────────────────────────────────────────────── // ─── Helpers ────────────────────────────────────────────────────────────────
function buildSlotStartMs(slotDate: Date, startTime: string): number { function buildSlotStartMs(slotDate: Date, startTime: string): number {
@@ -183,6 +187,7 @@ export class BookingService {
include: { include: {
timeSlot: true, timeSlot: true,
membership: { include: { cardType: true } }, membership: { include: { cardType: true } },
review: { select: { rating: true } },
}, },
}) })
@@ -455,6 +460,7 @@ export class BookingService {
} }
if (toStatus === BookingStatus.COMPLETED) { if (toStatus === BookingStatus.COMPLETED) {
updateData.completedAt = new Date() updateData.completedAt = new Date()
updateData.reviewReminderDueAt = new Date(Date.now() + 24 * 3600000)
} }
const updated = await tx.booking.update({ const updated = await tx.booking.update({
@@ -485,7 +491,7 @@ export class BookingService {
// ─── Cancel Booking ────────────────────────────────────────────────────── // ─── Cancel Booking ──────────────────────────────────────────────────────
async cancelBooking( async cancelBooking(
userId: string, actor: { id: string; isAdmin: boolean },
bookingId: string, bookingId: string,
): Promise<CancelBookingResult> { ): Promise<CancelBookingResult> {
const booking = await this.prisma.booking.findUnique({ const booking = await this.prisma.booking.findUnique({
@@ -499,10 +505,12 @@ export class BookingService {
if (!booking) { if (!booking) {
throw new NotFoundException(`Booking ${bookingId} not found`) throw new NotFoundException(`Booking ${bookingId} not found`)
} }
if (booking.userId !== userId) { // Members can only cancel their own bookings; admins can cancel any.
if (!actor.isAdmin && booking.userId !== actor.id) {
throw new ForbiddenException('This booking does not belong to you') throw new ForbiddenException('This booking does not belong to you')
} }
const actorLabel = actor.isAdmin ? '管理员' : '学员'
let refunded = false let refunded = false
// PENDING_CONFIRMATION: can cancel directly, no refund needed (times never deducted) // PENDING_CONFIRMATION: can cancel directly, no refund needed (times never deducted)
@@ -518,11 +526,12 @@ export class BookingService {
bookingId, bookingId,
fromStatus: BookingStatus.PENDING_CONFIRMATION, fromStatus: BookingStatus.PENDING_CONFIRMATION,
toStatus: BookingStatus.CANCELLED, toStatus: BookingStatus.CANCELLED,
operatorId: userId, operatorId: actor.id,
remark: '学员取消预约(待确认状态)', remark: `${actorLabel}取消预约(待确认状态)`,
}, },
}) })
}) })
await this.trySendBookingCancelledSubscriptionMessage(booking)
return { booking: { ...booking, status: BookingStatus.CANCELLED }, refunded } return { booking: { ...booking, status: BookingStatus.CANCELLED }, refunded }
} }
@@ -599,14 +608,18 @@ export class BookingService {
bookingId, bookingId,
fromStatus: BookingStatus.CONFIRMED, fromStatus: BookingStatus.CONFIRMED,
toStatus: BookingStatus.CANCELLED, toStatus: BookingStatus.CANCELLED,
operatorId: userId, operatorId: actor.id,
remark: refunded ? '学员取消预约(超时退款)' : '学员取消预约(未超时不退款)', remark: refunded
? `${actorLabel}取消预约(超时退款)`
: `${actorLabel}取消预约(未超时不退款)`,
}, },
}) })
return cancelled return cancelled
}) })
await this.trySendBookingCancelledSubscriptionMessage(booking)
return { booking: { ...updatedBooking }, refunded } return { booking: { ...updatedBooking }, refunded }
} }
@@ -628,6 +641,7 @@ export class BookingService {
include: { include: {
timeSlot: true, timeSlot: true,
membership: { include: { cardType: true } }, membership: { include: { cardType: true } },
review: { select: { rating: true } },
user: { select: { id: true, nickname: true, phone: true } }, user: { select: { id: true, nickname: true, phone: true } },
}, },
}) })
@@ -653,6 +667,7 @@ export class BookingService {
include: { include: {
timeSlot: true, timeSlot: true,
membership: { include: { cardType: true } }, membership: { include: { cardType: true } },
review: { select: { rating: true } },
}, },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit, skip: (page - 1) * limit,
@@ -714,6 +729,7 @@ export class BookingService {
include: { include: {
timeSlot: true, timeSlot: true,
membership: { include: { cardType: true } }, membership: { include: { cardType: true } },
review: { select: { rating: true } },
}, },
orderBy: [ orderBy: [
{ timeSlot: { date: 'asc' } }, { timeSlot: { date: 'asc' } },
@@ -726,22 +742,51 @@ export class BookingService {
// ─── Get All Bookings (Admin) ───────────────────────────────────────────── // ─── Get All Bookings (Admin) ─────────────────────────────────────────────
// “全部”视图下的展示优先级:待处理的事置顶,历史记录沉底。
// NO_SHOW 排在已完成之后,与前端“已完成”统计口径一致。
private static readonly STATUS_PRIORITY: readonly BookingStatus[] = [
BookingStatus.PENDING_CONFIRMATION,
BookingStatus.CONFIRMED,
BookingStatus.COMPLETED,
BookingStatus.NO_SHOW,
BookingStatus.CANCELLED,
]
private readonly adminBookingInclude = {
user: { select: { id: true, nickname: true, phone: true } },
timeSlot: true,
membership: { include: { cardType: true } },
review: { select: { rating: true } },
}
async getAllBookings( async getAllBookings(
page = 1, page = 1,
limit = 10, limit = 10,
status?: BookingStatus, status?: BookingStatus,
): Promise<PaginatedResult<BookingWithRelations & { user: { id: string; nickname: string; phone: string | null } }>> { ): Promise<PaginatedResult<AdminBookingRow>> {
const where = status ? { status } : {} return status ? this.getBookingsPageByStatus(page, limit, status) : this.getAllBookingsPageByStatusPriority(page, limit)
}
// Confirmed-but-not-yet-completed sessions are the ones a teacher is about
// to run, so sort them by upcoming time (soonest first). For every other
// status (PENDING / COMPLETED / CANCELLED / NO_SHOW), creation order
// (newest first) is the more useful default.
private bookingsOrderByForStatus(status: BookingStatus) {
return status === BookingStatus.CONFIRMED
? [
{ timeSlot: { date: 'asc' as const } },
{ timeSlot: { startTime: 'asc' as const } },
]
: { createdAt: 'desc' as const }
}
private async getBookingsPageByStatus(page: number, limit: number, status: BookingStatus) {
const where = { status }
const [bookings, total] = await Promise.all([ const [bookings, total] = await Promise.all([
this.prisma.booking.findMany({ this.prisma.booking.findMany({
where, where,
include: { include: this.adminBookingInclude,
user: { select: { id: true, nickname: true, phone: true } }, orderBy: this.bookingsOrderByForStatus(status),
timeSlot: true,
membership: { include: { cardType: true } },
},
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit, skip: (page - 1) * limit,
take: limit, take: limit,
}), }),
@@ -749,9 +794,49 @@ export class BookingService {
]) ])
return { return {
data: bookings.map((b) => ({ ...b })) as unknown as (BookingWithRelations & { data: bookings.map((b) => ({ ...b })) as unknown as AdminBookingRow[],
user: { id: string; nickname: string; phone: string | null } total,
})[], page,
limit,
}
}
// Prisma 的 orderBy 表达不了 CASE 式的状态优先级,所以“全部”视图先按状态
// groupBy 计数,再把分页窗口按优先级切进各状态段分别查询,最后按优先级拼接。
private async getAllBookingsPageByStatusPriority(page: number, limit: number) {
const groups = await this.prisma.booking.groupBy({
by: ['status'],
_count: { _all: true },
})
const counts = new Map(groups.map((g) => [g.status, g._count._all]))
const total = BookingService.STATUS_PRIORITY.reduce((sum, s) => sum + (counts.get(s) ?? 0), 0)
const windowStart = (page - 1) * limit
const windowEnd = windowStart + limit
let segmentStart = 0
const queries: Promise<unknown[]>[] = []
for (const status of BookingService.STATUS_PRIORITY) {
const segmentCount = counts.get(status) ?? 0
if (segmentCount === 0) continue
const segmentEnd = segmentStart + segmentCount
if (segmentEnd > windowStart && segmentStart < windowEnd) {
queries.push(
this.prisma.booking.findMany({
where: { status },
include: this.adminBookingInclude,
orderBy: this.bookingsOrderByForStatus(status),
skip: Math.max(0, windowStart - segmentStart),
take: Math.min(segmentEnd, windowEnd) - Math.max(segmentStart, windowStart),
}),
)
}
segmentStart = segmentEnd
}
const segments = await Promise.all(queries)
return {
data: segments.flat() as unknown as AdminBookingRow[],
total, total,
page, page,
limit, limit,
@@ -764,20 +849,22 @@ export class BookingService {
throw new BadRequestException('Invalid date') throw new BadRequestException('Invalid date')
} }
// 当日的课表需要包含所有状态的预约:老师下课后,已核销 / 标记未到 / 已取消
// 的预约不应从课表里消失,状态由前端标签呈现;其他日期维持「只看待上课」语义。
const showAllStatuses = this.isLocalToday(date)
const activeBookingFilter = {
status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] },
}
const slots = await this.prisma.timeSlot.findMany({ const slots = await this.prisma.timeSlot.findMany({
where: { where: {
date: dayStart, date: dayStart,
bookings: { // 仍用 EXISTS 守卫过滤掉完全没人预约的空 slot但今天放开状态过滤。
some: { bookings: { some: showAllStatuses ? {} : activeBookingFilter },
status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] },
},
},
}, },
include: { include: {
bookings: { bookings: {
where: { ...(showAllStatuses ? {} : { where: activeBookingFilter }),
status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] },
},
include: { include: {
user: { user: {
select: { select: {
@@ -824,6 +911,14 @@ export class BookingService {
}) })
} }
private isLocalToday(date: string): boolean {
return date === this.formatLocalDate(new Date())
}
private formatLocalDate(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
// ─── Private Helpers ───────────────────────────────────────────────────── // ─── Private Helpers ─────────────────────────────────────────────────────
private async fetchBookingWithRelations(bookingId: string): Promise<BookingWithRelations> { private async fetchBookingWithRelations(bookingId: string): Promise<BookingWithRelations> {
@@ -832,6 +927,7 @@ export class BookingService {
include: { include: {
timeSlot: true, timeSlot: true,
membership: { include: { cardType: true } }, membership: { include: { cardType: true } },
review: { select: { rating: true } },
}, },
}) })
@@ -855,8 +951,7 @@ export class BookingService {
} }
const studio = await this.studioService.getInfo() const studio = await this.studioService.getInfo()
const bookingDate = booking.timeSlot.date const dateLabel = this.formatLocalDate(booking.timeSlot.date)
const dateLabel = `${bookingDate.getFullYear()}-${String(bookingDate.getMonth() + 1).padStart(2, '0')}-${String(bookingDate.getDate()).padStart(2, '0')}`
await this.subscriptionMessageService.sendBookingConfirmedMessage({ await this.subscriptionMessageService.sendBookingConfirmedMessage({
openid: user.openid, openid: user.openid,
@@ -871,6 +966,39 @@ export class BookingService {
} }
} }
private async trySendBookingCancelledSubscriptionMessage(
booking: {
id: string
userId: string
timeSlot: { date: Date; startTime: string; endTime: string }
},
): Promise<void> {
try {
const user = await this.prisma.user.findUnique({
where: { id: booking.userId },
select: { openid: true },
})
if (!user?.openid) {
return
}
const studio = await this.studioService.getInfo()
const dateLabel = this.formatLocalDate(booking.timeSlot.date)
const courseTime = `${booking.timeSlot.startTime.slice(0, 5)}-${booking.timeSlot.endTime.slice(0, 5)}`
await this.subscriptionMessageService.sendBookingCancelledMessage({
openid: user.openid,
userId: booking.userId,
bookingId: booking.id,
bookingDate: dateLabel,
courseTime,
courseName: studio.name || '普拉提课程',
})
} catch (error) {
console.error('Send booking cancelled subscription message failed:', error)
}
}
private async trySendAdminBookingCreatedSubscriptionMessages( private async trySendAdminBookingCreatedSubscriptionMessages(
booking: BookingWithRelations, booking: BookingWithRelations,
): Promise<void> { ): Promise<void> {
@@ -894,8 +1022,7 @@ export class BookingService {
select: { nickname: true, phone: true }, select: { nickname: true, phone: true },
}) })
const studio = await this.studioService.getInfo() const studio = await this.studioService.getInfo()
const bookingDate = booking.timeSlot.date const dateLabel = this.formatLocalDate(booking.timeSlot.date)
const dateLabel = `${bookingDate.getFullYear()}-${String(bookingDate.getMonth() + 1).padStart(2, '0')}-${String(bookingDate.getDate()).padStart(2, '0')}`
const studentLabel = this.buildAdminBookingStudentLabel(student) const studentLabel = this.buildAdminBookingStudentLabel(student)
await Promise.allSettled( await Promise.allSettled(
admins admins

View File

@@ -0,0 +1,8 @@
import { ArrayMaxSize, ArrayUnique, IsArray, IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator'
import { REVIEW_TAGS } from '@mp-pilates/shared'
export class CreateReviewDto {
@IsInt() @Min(1) @Max(5) rating!: number
@IsOptional() @IsInt() @Min(0) @Max(10) recommendation?: number
@IsArray() @ArrayMaxSize(3) @ArrayUnique() @IsIn(REVIEW_TAGS, { each: true }) tags!: string[]
@IsString() @MaxLength(200) comment!: string
}

View File

@@ -0,0 +1,27 @@
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common'
import { UserRole } from '@mp-pilates/shared'
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
import { RolesGuard } from '../auth/roles.guard'
import { Roles } from '../auth/roles.decorator'
import { CurrentUser } from '../common/decorators/current-user.decorator'
import { ReviewService } from './review.service'
import { CreateReviewDto } from './dto/create-review.dto'
@Controller()
@UseGuards(JwtAuthGuard)
export class ReviewController {
constructor(private readonly service: ReviewService) {}
@Get('booking/:id/review')
get(@CurrentUser('sub') userId: string, @Param('id') id: string) { return this.service.get(userId, id) }
@Post('booking/:id/review')
create(@CurrentUser('sub') userId: string, @Param('id') id: string, @Body() dto: CreateReviewDto) { return this.service.create(userId, id, dto) }
@Get('admin/reviews') @UseGuards(RolesGuard) @Roles(UserRole.ADMIN)
list(@Query('userId') userId?: string, @Query('page') page?: string) { return this.service.list(userId, page ? Number(page) : 1) }
@Get('admin/reviews/trend') @UseGuards(RolesGuard) @Roles(UserRole.ADMIN)
trend(@Query('month') month?: string) { return this.service.trend(month) }
}
@Controller('reviews')
export class PublicReviewController {
constructor(private readonly service: ReviewService) {}
@Get('summary') summary() { return this.service.publicSummary() }
}

View File

@@ -0,0 +1,61 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'
import { Prisma } from '@prisma/client'
import { PrismaService } from '../prisma/prisma.service'
import { CreateReviewDto } from './dto/create-review.dto'
export function summarizeReviews(rows: { rating: number; recommendation: number | null }[]) {
const recommendations = rows.filter(r => r.recommendation !== null)
return {
count: rows.length,
average: rows.length ? Math.round(rows.reduce((sum, r) => sum + r.rating, 0) / rows.length * 10) / 10 : null,
npsCount: recommendations.length,
nps: recommendations.length ? Math.round(100 * (recommendations.filter(r => r.recommendation! >= 9).length - recommendations.filter(r => r.recommendation! <= 6).length) / recommendations.length) : null,
}
}
@Injectable()
export class ReviewService {
constructor(private readonly prisma: PrismaService) {}
async get(userId: string, bookingId: string) {
const booking = await this.prisma.booking.findFirst({ where: { id: bookingId, userId }, include: { review: true } })
if (!booking) throw new NotFoundException('预约不存在')
return { review: booking.review, canReview: booking.status === 'COMPLETED' && !booking.review }
}
async create(userId: string, bookingId: string, dto: CreateReviewDto) {
const booking = await this.prisma.booking.findFirst({ where: { id: bookingId, userId } })
if (!booking) throw new NotFoundException('预约不存在')
if (booking.status !== 'COMPLETED') throw new BadRequestException('完成课程后才能评价')
try {
return await this.prisma.bookingReview.create({ data: { bookingId, rating: dto.rating, recommendation: dto.recommendation, tags: dto.tags, comment: dto.comment.trim() } })
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') throw new BadRequestException('这节课已经评价过了')
throw error
}
}
async publicSummary() {
const result = await this.prisma.bookingReview.aggregate({ _count: { id: true }, _avg: { rating: true } })
return { count: result._count.id, average: result._avg.rating === null ? null : Math.round(result._avg.rating * 10) / 10 }
}
async list(userId?: string, page = 1) {
if (!Number.isInteger(page) || page < 1 || page > 10000) throw new BadRequestException('页码无效')
const where = userId ? { booking: { userId } } : {}
const [data, total] = await Promise.all([
this.prisma.bookingReview.findMany({ where, orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], skip: (page - 1) * 20, take: 20,
include: { booking: { select: { userId: true, user: { select: { nickname: true } }, timeSlot: { select: { date: true, startTime: true, endTime: true } } } } } }),
this.prisma.bookingReview.count({ where }),
])
return { data, total, page, limit: 20 }
}
async trend(month?: string) {
const current = month || new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 7)
if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(current)) throw new BadRequestException('月份格式应为 YYYY-MM')
const end = new Date(current + '-01T00:00:00+08:00')
const months = Array.from({ length: 6 }, (_, i) => {
const date = new Date(Date.UTC(end.getUTCFullYear(), end.getUTCMonth() + 1 - (5 - i), 1))
return date.toISOString().slice(0, 7)
})
const from = new Date(months[0] + '-01T00:00:00+08:00')
const to = new Date(Date.UTC(Number(current.slice(0, 4)), Number(current.slice(5)), 1) - 8 * 3600000)
const rows = await this.prisma.bookingReview.findMany({ where: { createdAt: { gte: from, lt: to } }, select: { rating: true, recommendation: true, createdAt: true } })
return months.map(month => ({ month, ...summarizeReviews(rows.filter(r => new Date(r.createdAt.getTime() + 8 * 3600000).toISOString().startsWith(month))) }))
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,71 @@
import { InviteService } from '../invite.service'
import { PrismaService } from '../../prisma/prisma.service'
import type { Prisma } from '@prisma/client'
describe('InviteService marketing rules', () => {
function setup() {
const prisma = {
user: { findUniqueOrThrow: jest.fn(), updateMany: jest.fn(), findUnique: jest.fn() },
inviteReferral: { upsert: jest.fn(), findUnique: jest.fn(), updateMany: jest.fn() },
membership: { create: jest.fn() }, inviteRewardGrant: { create: jest.fn() },
}
return { prisma, service: new InviteService(prisma as unknown as PrismaService) }
}
it('lazily reuses existing short codes without writing', async () => {
const { prisma, service } = setup()
prisma.user.findUniqueOrThrow.mockResolvedValue({ inviteCode: 'ABC234' })
expect(await service.ensureCode('user')).toBe('ABC234')
expect(prisma.user.updateMany).not.toHaveBeenCalled()
})
it('retries code collisions and only writes if still missing', async () => {
const { prisma, service } = setup()
prisma.user.findUniqueOrThrow.mockResolvedValue({ inviteCode: null })
prisma.user.updateMany.mockRejectedValueOnce({ code: 'P2002' }).mockResolvedValueOnce({ count: 1 })
expect(await service.ensureCode('user')).toMatch(/^[23456789A-HJ-NP-Z]{6}$/)
expect(prisma.user.updateMany).toHaveBeenCalledTimes(2)
expect(prisma.user.updateMany.mock.calls[0][0].where).toEqual({ id: 'user', inviteCode: null })
})
it('reads the winning code when concurrent generation already filled it', async () => {
const { prisma, service } = setup()
prisma.user.findUniqueOrThrow.mockResolvedValueOnce({ inviteCode: null }).mockResolvedValueOnce({ inviteCode: 'ABC234' })
prisma.user.updateMany.mockResolvedValue({ count: 0 })
expect(await service.ensureCode('user')).toBe('ABC234')
})
it('rejects invalid, self and changed inviter codes', async () => {
const { prisma, service } = setup()
prisma.user.findUnique.mockResolvedValueOnce(null).mockResolvedValueOnce({ id: 'self' }).mockResolvedValueOnce({ id: 'friend' })
await expect(service.confirmCode('self', 'ABC234')).rejects.toThrow('不存在')
await expect(service.confirmCode('self', 'ABC234')).rejects.toThrow('自己')
prisma.inviteReferral.upsert.mockResolvedValue({ inviterId: 'original' })
await expect(service.confirmCode('self', 'ABC234')).rejects.toThrow('其他好友')
})
it('accepts repeat confirmation and recovers a concurrent binding to the same inviter', async () => {
const { prisma, service } = setup()
prisma.user.findUnique.mockResolvedValue({ id: 'friend' })
prisma.user.findUniqueOrThrow.mockResolvedValue({ inviteCode: 'XYZ234' })
prisma.inviteReferral.upsert.mockRejectedValueOnce({ code: 'P2002' }).mockResolvedValueOnce({ inviterId: 'friend' })
prisma.inviteReferral.findUnique.mockResolvedValue({ inviterId: 'friend' })
expect(await service.confirmCode('self', 'ABC234')).toEqual({ inviteCode: 'XYZ234', discountEligible: true })
expect(await service.confirmCode('self', 'ABC234')).toEqual({ inviteCode: 'XYZ234', discountEligible: true })
})
it.each(['TRIAL', null])('does not reward category %s', async (purchasedCategory) => {
const { prisma, service } = setup()
await service.rewardPaidOrder(prisma as unknown as Prisma.TransactionClient, { userId: 'user', inviteInviterId: 'friend', purchasedCategory }, new Date())
expect(prisma.inviteReferral.updateMany).not.toHaveBeenCalled()
await service.recordQualifiedTrialBooking('booking')
expect(prisma.membership.create).not.toHaveBeenCalled()
})
it.each(['TIMES', 'DURATION'])('grants one usable class for %s even without an existing membership', async (purchasedCategory) => {
const { prisma, service } = setup()
prisma.inviteReferral.updateMany.mockResolvedValueOnce({ count: 1 }).mockResolvedValueOnce({ count: 0 })
prisma.membership.create.mockResolvedValue({ id: 'reward' })
const order = { userId: 'user', inviteInviterId: 'friend', purchasedCategory }
const tx = prisma as unknown as Prisma.TransactionClient
await service.rewardPaidOrder(tx, order, new Date())
await service.rewardPaidOrder(tx, order, new Date())
expect(prisma.membership.create).toHaveBeenCalledTimes(1)
expect(prisma.membership.create).toHaveBeenCalledWith({ data: expect.objectContaining({ userId: 'friend', remainingTimes: 1, totalTimes: 1, status: 'ACTIVE' }) })
expect(prisma.inviteRewardGrant.create).toHaveBeenCalledTimes(1)
})
})

View File

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

View File

@@ -1,13 +1,30 @@
import { Controller, Get, UseGuards } from '@nestjs/common' import { IsString, Matches } from 'class-validator'
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common'
import { JwtAuthGuard } from '../auth/jwt-auth.guard' import { JwtAuthGuard } from '../auth/jwt-auth.guard'
import { CurrentUser } from '../common/decorators/current-user.decorator' import { CurrentUser } from '../common/decorators/current-user.decorator'
import { InviteService } from './invite.service' import { InviteService } from './invite.service'
class ConfirmInviteDto {
@IsString()
@Matches(/^[23456789A-HJ-NP-Z]{6}$/i)
code!: string
}
@Controller('invite') @Controller('invite')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
export class InviteController { export class InviteController {
constructor(private readonly inviteService: InviteService) {} constructor(private readonly inviteService: InviteService) {}
@Get('code')
getCode(@CurrentUser('sub') userId: string) {
return this.inviteService.getCodeStatus(userId)
}
@Post('confirm')
confirm(@CurrentUser('sub') userId: string, @Body() body: ConfirmInviteDto) {
return this.inviteService.confirmCode(userId, body.code)
}
@Get('activity') @Get('activity')
getActivity(@CurrentUser('sub') userId: string) { getActivity(@CurrentUser('sub') userId: string) {
return this.inviteService.getInviteActivitySummary(userId) return this.inviteService.getInviteActivitySummary(userId)

View File

@@ -3,7 +3,8 @@ import {
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common' } from '@nestjs/common'
import type { InviteReferral, InviteRewardGrant, Membership } from '@prisma/client' import { randomInt } from 'crypto'
import type { Prisma, InviteReferral, InviteRewardGrant } from '@prisma/client'
import { InviteReferralStatus, MembershipStatus, OrderStatus } from '@mp-pilates/shared' import { InviteReferralStatus, MembershipStatus, OrderStatus } from '@mp-pilates/shared'
import type { InviteActivitySummary } from '@mp-pilates/shared' import type { InviteActivitySummary } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service' import { PrismaService } from '../prisma/prisma.service'
@@ -70,50 +71,72 @@ export class InviteService {
}) })
} }
async recordQualifiedTrialBooking(bookingId: string): Promise<void> { // Legacy booking callbacks must never qualify trial purchases.
const booking = await this.prisma.booking.findUnique({ async recordQualifiedTrialBooking(_bookingId: string): Promise<void> {}
where: { id: bookingId },
include: {
membership: { include: { cardType: true } },
},
})
if (!booking || booking.status !== 'COMPLETED' || !this.isTrialCardType(booking.membership.cardType.type)) { async ensureCode(userId: string): Promise<string> {
return for (let attempt = 0; attempt < 12; attempt++) {
const user = await this.prisma.user.findUniqueOrThrow({ where: { id: userId } })
if (user.inviteCode) return user.inviteCode
const alphabet = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ'
const code = Array.from({ length: 6 }, () => alphabet[randomInt(alphabet.length)]).join('')
try {
const updated = await this.prisma.user.updateMany({
where: { id: userId, inviteCode: null }, data: { inviteCode: code },
})
if (updated.count) return code
} catch (error) {
if ((error as { code?: string }).code !== 'P2002') throw error
}
}
throw new BadRequestException('邀请码生成繁忙,请重试')
} }
const referral = await this.prisma.inviteReferral.findFirst({ async getCodeStatus(userId: string) {
where: { const inviteCode = await this.ensureCode(userId)
inviteeId: booking.userId, const referral = await this.prisma.inviteReferral.findUnique({ where: { inviteeId: userId } })
status: { return { inviteCode, discountEligible: !!referral }
in: [InviteReferralStatus.REGISTERED, InviteReferralStatus.TRIAL_PURCHASED],
},
qualifiedBookingId: null,
},
orderBy: { createdAt: 'asc' },
})
if (!referral) {
return
} }
await this.prisma.inviteReferral.update({ async confirmCode(userId: string, code: string) {
where: { id: referral.id }, const inviter = await this.prisma.user.findUnique({ where: { inviteCode: code.toUpperCase() } })
data: { if (!inviter) throw new BadRequestException('邀请码不存在,请检查后重试')
status: InviteReferralStatus.QUALIFIED, if (inviter.id === userId) throw new BadRequestException('不能使用自己的邀请码')
qualifiedBookingId: booking.id, let referral: InviteReferral | null
qualifiedAt: booking.completedAt ?? new Date(), try {
}, referral = await this.prisma.inviteReferral.upsert({
where: { inviteeId: userId }, update: {},
create: { inviterId: inviter.id, inviteeId: userId },
}) })
} catch (error) {
// MySQL upserts may race on the unique invitee key. Read the winner.
if ((error as { code?: string }).code !== 'P2002') throw error
referral = await this.prisma.inviteReferral.findUnique({ where: { inviteeId: userId } })
if (!referral) throw error
}
if (referral.inviterId !== inviter.id) throw new BadRequestException('你已绑定其他好友的邀请码,已享有 95 折优惠')
return this.getCodeStatus(userId)
}
await this.grantRewardsIfEligible(referral.inviterId) async rewardPaidOrder(tx: Prisma.TransactionClient, order: { userId: string; inviteInviterId: string | null; purchasedCategory: string | null }, now: Date) {
if (!order.inviteInviterId || !order.purchasedCategory || order.purchasedCategory === 'TRIAL') return
const claimed = await tx.inviteReferral.updateMany({
where: { inviteeId: order.userId, inviterId: order.inviteInviterId, status: { not: 'QUALIFIED' } },
data: { status: 'QUALIFIED', qualifiedAt: now },
})
if (!claimed.count) return
const membership = await tx.membership.create({ data: {
userId: order.inviteInviterId, cardTypeId: 'invite-reward-card',
remainingTimes: 1, totalTimes: 1, startDate: now,
expireDate: new Date(now.getTime() + 365 * 86400000), status: 'ACTIVE',
} })
await tx.inviteRewardGrant.create({ data: {
inviterId: order.inviteInviterId, membershipId: membership.id,
qualifiedReferralCount: 1, rewardTimes: 1,
} })
} }
async getInviteActivitySummary(userId: string): Promise<InviteActivitySummary> { 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({ const referrals = await this.prisma.inviteReferral.findMany({
where: { inviterId: userId }, where: { inviterId: userId },
include: { include: {
@@ -132,7 +155,7 @@ export class InviteService {
orderBy: { grantedAt: 'desc' }, orderBy: { grantedAt: 'desc' },
}) })
const canInvite = memberships.some((membership: Membership) => membership.status === MembershipStatus.ACTIVE) const canInvite = true
const qualifiedInviteCount = referrals.filter((item: InviteReferral) => item.status === InviteReferralStatus.QUALIFIED).length const qualifiedInviteCount = referrals.filter((item: InviteReferral) => item.status === InviteReferralStatus.QUALIFIED).length
const rewardedTimes = rewardGrants.reduce((sum: number, item: InviteRewardGrant) => sum + item.rewardTimes, 0) const rewardedTimes = rewardGrants.reduce((sum: number, item: InviteRewardGrant) => sum + item.rewardTimes, 0)
const pendingRewardGrantCount = Math.max( const pendingRewardGrantCount = Math.max(
@@ -144,7 +167,7 @@ export class InviteService {
return { return {
inviterId: userId, inviterId: userId,
canInvite, canInvite,
sharePath: `/pages/profile/invite?inviterId=${userId}`, sharePath: `/pages/card/detail?showAll=1&inviteCode=${await this.ensureCode(userId)}`,
rewardRuleInvitesRequired: INVITE_REWARD_REQUIRED_COUNT, rewardRuleInvitesRequired: INVITE_REWARD_REQUIRED_COUNT,
rewardRuleTimes: INVITE_REWARD_TIMES, rewardRuleTimes: INVITE_REWARD_TIMES,
qualifiedInviteCount, qualifiedInviteCount,
@@ -196,58 +219,4 @@ export class InviteService {
} }
} }
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

@@ -41,6 +41,7 @@ const mockUser = {
const mockInviteService = { const mockInviteService = {
validateInviterForTrialOrder: jest.fn(), validateInviterForTrialOrder: jest.fn(),
recordTrialOrderPaid: jest.fn(), recordTrialOrderPaid: jest.fn(),
rewardPaidOrder: jest.fn(),
} }
const buildMockOrder = (overrides: Partial<Record<string, unknown>> = {}) => ({ const buildMockOrder = (overrides: Partial<Record<string, unknown>> = {}) => ({
@@ -69,6 +70,7 @@ const mockPaymentParams = {
function buildPrismaMock() { function buildPrismaMock() {
return { return {
inviteReferral: { findUnique: jest.fn().mockResolvedValue(null) },
cardType: { cardType: {
findUnique: jest.fn(), findUnique: jest.fn(),
}, },
@@ -76,6 +78,7 @@ function buildPrismaMock() {
findUnique: jest.fn(), findUnique: jest.fn(),
}, },
order: { order: {
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
create: jest.fn(), create: jest.fn(),
findUnique: jest.fn(), findUnique: jest.fn(),
update: jest.fn(), update: jest.fn(),
@@ -87,9 +90,6 @@ function buildPrismaMock() {
findFirst: jest.fn(), findFirst: jest.fn(),
update: jest.fn(), update: jest.fn(),
}, },
flashSaleOrder: {
updateMany: jest.fn(),
},
$transaction: jest.fn(), $transaction: jest.fn(),
} }
} }
@@ -131,6 +131,16 @@ describe('PaymentService', () => {
// ─── createOrder ──────────────────────────────────────────────────────────── // ─── createOrder ────────────────────────────────────────────────────────────
describe('createOrder', () => { describe('createOrder', () => {
it.each(['TRIAL', 'TIMES', 'DURATION'])('applies 95 percent server-side to %s and snapshots attribution', async (type) => {
prisma.cardType.findUnique.mockResolvedValue({ ...mockCardType, type, price: new Decimal(999) })
prisma.user.findUnique.mockResolvedValue(mockUser)
prisma.inviteReferral.findUnique.mockResolvedValue({ inviterId: 'friend' })
prisma.order.create.mockResolvedValue(buildMockOrder({ amount: new Decimal(949) }))
await service.createOrder(mockUser.id, mockCardType.id)
expect(prisma.order.create).toHaveBeenCalledWith({ data: expect.objectContaining({ amount: 949, inviteInviterId: 'friend', purchasedCategory: type }) })
expect(wechat.createUnifiedOrder).toHaveBeenCalledWith(expect.objectContaining({ amount: 949 }))
})
it('creates a PENDING order with correct amount and formatted orderNo', async () => { it('creates a PENDING order with correct amount and formatted orderNo', async () => {
prisma.cardType.findUnique.mockResolvedValue(mockCardType) prisma.cardType.findUnique.mockResolvedValue(mockCardType)
prisma.user.findUnique.mockResolvedValue(mockUser) prisma.user.findUnique.mockResolvedValue(mockUser)
@@ -146,7 +156,7 @@ describe('PaymentService', () => {
data: expect.objectContaining({ data: expect.objectContaining({
userId: mockUser.id, userId: mockUser.id,
cardTypeId: mockCardType.id, cardTypeId: mockCardType.id,
amount: mockCardType.price, amount: Number(mockCardType.price),
status: OrderStatus.PENDING, status: OrderStatus.PENDING,
}), }),
}), }),
@@ -246,6 +256,18 @@ describe('PaymentService', () => {
prisma.$transaction.mockImplementation(async (fn: (tx: typeof prisma) => Promise<unknown>) => fn(prisma)) prisma.$transaction.mockImplementation(async (fn: (tx: typeof prisma) => Promise<unknown>) => fn(prisma))
}) })
it('does not grant membership or referral rewards when another callback claimed the order', async () => {
prisma.order.updateMany.mockResolvedValue({ count: 0 })
await service.handleWxNotify(headers, successBody)
expect(prisma.membership.create).not.toHaveBeenCalled()
expect(mockInviteService.rewardPaidOrder).not.toHaveBeenCalled()
})
it('propagates reward issuance failure out of the payment transaction', async () => {
mockInviteService.rewardPaidOrder.mockRejectedValueOnce(new Error('reward failed'))
await expect(service.handleWxNotify(headers, successBody)).rejects.toThrow('reward failed')
})
it('marks order as PAID and grants a new membership on valid callback', async () => { it('marks order as PAID and grants a new membership on valid callback', async () => {
const result = await service.handleWxNotify(headers, successBody) const result = await service.handleWxNotify(headers, successBody)

View File

@@ -5,7 +5,7 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common' } from '@nestjs/common'
import { CardType, Order } from '@prisma/client' import { CardType, Order } from '@prisma/client'
import { OrderStatus, FlashSaleOrderStatus } from '@mp-pilates/shared' import { OrderStatus } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service' import { PrismaService } from '../prisma/prisma.service'
import { WechatPayService, WxPaymentParams } from './wechat-pay.service' import { WechatPayService, WxPaymentParams } from './wechat-pay.service'
import { InviteService } from '../invite/invite.service' import { InviteService } from '../invite/invite.service'
@@ -55,6 +55,8 @@ export class PaymentService {
await this.inviteService.validateInviterForTrialOrder(userId, inviterId) await this.inviteService.validateInviterForTrialOrder(userId, inviterId)
} }
const referral = await this.prisma.inviteReferral.findUnique({ where: { inviteeId: userId } })
const amount = referral ? Math.round(Number(cardType.price) * 95 / 100) : Number(cardType.price)
const orderNo = `${Date.now()}${Math.random().toString(36).substring(2, 8)}` const orderNo = `${Date.now()}${Math.random().toString(36).substring(2, 8)}`
const order = await this.prisma.order.create({ const order = await this.prisma.order.create({
@@ -62,14 +64,16 @@ export class PaymentService {
userId, userId,
cardTypeId, cardTypeId,
orderNo, orderNo,
amount: cardType.price, amount,
inviteInviterId: referral?.inviterId,
purchasedCategory: cardType.type,
status: OrderStatus.PENDING, status: OrderStatus.PENDING,
}, },
}) })
const paymentParams = await this.wechatPayService.createUnifiedOrder({ const paymentParams = await this.wechatPayService.createUnifiedOrder({
orderNo, orderNo,
amount: Number(cardType.price), amount,
openid: user.openid, openid: user.openid,
description: cardType.name, description: cardType.name,
}) })
@@ -122,6 +126,11 @@ export class PaymentService {
const now = new Date() const now = new Date()
await this.prisma.$transaction(async (tx) => { await this.prisma.$transaction(async (tx) => {
const claimed = await tx.order.updateMany({
where: { id: existingOrder.id, status: OrderStatus.PENDING },
data: { status: OrderStatus.PAID },
})
if (!claimed.count) return
const membership = await this.membershipService.grantPurchasedCard( const membership = await this.membershipService.grantPurchasedCard(
tx, tx,
existingOrder.userId, existingOrder.userId,
@@ -138,27 +147,13 @@ export class PaymentService {
membershipId: membership.id, membershipId: membership.id,
}, },
}) })
await this.inviteService.rewardPaidOrder(tx, existingOrder, now)
}) })
await this.inviteService.recordTrialOrderPaid(existingOrder.id) await this.inviteService.recordTrialOrderPaid(existingOrder.id)
this.logger.log(`Order PAID and membership granted: orderNo=${notification.orderNo}`) this.logger.log(`Order PAID and membership granted: orderNo=${notification.orderNo}`)
// ── Flash sale order: mark as PAID ──
if (existingOrder.flashSaleId) {
await this.prisma.flashSaleOrder.updateMany({
where: {
orderId: existingOrder.id,
status: FlashSaleOrderStatus.RESERVED,
},
data: {
status: FlashSaleOrderStatus.PAID,
paidAt: now,
},
})
this.logger.log(`Flash sale order marked PAID for orderNo=${notification.orderNo}`)
}
return this.buildSuccessXml() return this.buildSuccessXml()
} }

View File

@@ -0,0 +1,122 @@
import { ClassReminderService } from '../class-reminder.service'
import { PrismaService } from '../../prisma/prisma.service'
import { SubscriptionMessageService } from '../../user/subscription-message.service'
import { ConfigService } from '@nestjs/config'
import { Logger } from '@nestjs/common'
import { BookingStatus } from '@mp-pilates/shared'
describe('ClassReminderService', () => {
const db = {
booking: { findMany: jest.fn(), updateMany: jest.fn(), update: jest.fn() },
studioConfig: { findFirst: jest.fn() },
}
const messages = {
getClassReminderTemplateId: jest.fn(),
sendClassReminderMessage: jest.fn(),
}
const config = { get: jest.fn() }
let service: ClassReminderService
beforeEach(() => {
jest.resetAllMocks()
messages.getClassReminderTemplateId.mockReturnValue('reminder-template-id')
db.studioConfig.findFirst.mockResolvedValue({ name: 'FocusCore Studio' })
service = new ClassReminderService(
db as unknown as PrismaService,
messages as unknown as SubscriptionMessageService,
config as unknown as ConfigService,
)
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => {})
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => {})
})
afterEach(() => jest.restoreAllMocks())
it('skips run when class reminder template is not configured', async () => {
messages.getClassReminderTemplateId.mockReturnValue('')
await service.run()
expect(db.booking.findMany).not.toHaveBeenCalled()
})
it('scans and sends reminders for bookings starting in ~1 hour (e.g. 60 mins)', async () => {
// Current time fixed or calculated
const now = new Date()
const targetDate = new Date(now.getTime() + 60 * 60 * 1000)
// Convert targetDate to China time string representation
// China is UTC+8
const chinaDate = new Date(targetDate.getTime() + 8 * 3600 * 1000)
const dateStr = chinaDate.toISOString().slice(0, 10)
const hours = String(chinaDate.getUTCHours()).padStart(2, '0')
const minutes = String(chinaDate.getUTCMinutes()).padStart(2, '0')
const startTime = `${hours}:${minutes}:00`
db.booking.findMany.mockResolvedValue([
{
id: 'booking-reminder-1',
userId: 'user-1',
status: BookingStatus.CONFIRMED,
user: { openid: 'openid-user-1' },
timeSlot: {
date: new Date(`${dateStr}T00:00:00.000Z`),
startTime,
endTime: '12:00:00',
},
},
])
db.booking.updateMany.mockResolvedValue({ count: 1 })
messages.sendClassReminderMessage.mockResolvedValue(true)
await service.run()
expect(db.booking.updateMany).toHaveBeenCalledWith({
where: {
id: 'booking-reminder-1',
status: BookingStatus.CONFIRMED,
classReminderClaimedAt: null,
},
data: { classReminderClaimedAt: expect.any(Date) },
})
expect(messages.sendClassReminderMessage).toHaveBeenCalledWith(
expect.objectContaining({
openid: 'openid-user-1',
userId: 'user-1',
bookingId: 'booking-reminder-1',
courseName: 'FocusCore Studio',
}),
)
expect(db.booking.update).toHaveBeenCalledWith({
where: { id: 'booking-reminder-1' },
data: { classReminderSentAt: expect.any(Date) },
})
})
it('skips bookings that are not in the 50-70 minutes window (e.g. starting in 2 hours)', async () => {
const now = new Date()
const targetDate = new Date(now.getTime() + 120 * 60 * 1000)
const chinaDate = new Date(targetDate.getTime() + 8 * 3600 * 1000)
const dateStr = chinaDate.toISOString().slice(0, 10)
const hours = String(chinaDate.getUTCHours()).padStart(2, '0')
const minutes = String(chinaDate.getUTCMinutes()).padStart(2, '0')
db.booking.findMany.mockResolvedValue([
{
id: 'booking-far',
userId: 'user-far',
status: BookingStatus.CONFIRMED,
user: { openid: 'openid-far' },
timeSlot: {
date: new Date(`${dateStr}T00:00:00.000Z`),
startTime: `${hours}:${minutes}:00`,
endTime: '20:00:00',
},
},
])
await service.run()
expect(db.booking.updateMany).not.toHaveBeenCalled()
expect(messages.sendClassReminderMessage).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,29 @@
import { ReviewReminderService } from '../review-reminder.service'
import { PrismaService } from '../../prisma/prisma.service'
import { SubscriptionMessageService } from '../../user/subscription-message.service'
import { ConfigService } from '@nestjs/config'
import { Logger } from '@nestjs/common'
describe('Review reminder queue', () => {
const db = { booking: { findMany: jest.fn(), updateMany: jest.fn(), update: jest.fn() } }
const messages = { sendReviewReminder: jest.fn() }, config = { get: jest.fn() }
let service: ReviewReminderService
beforeEach(() => {
jest.resetAllMocks()
config.get.mockReturnValue('tmpl')
service = new ReviewReminderService(db as unknown as PrismaService, messages as unknown as SubscriptionMessageService, config as unknown as ConfigService)
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => {})
db.booking.findMany.mockResolvedValue([{ id: 'b', userId: 'u', user: { openid: 'o' } }])
})
afterEach(() => jest.restoreAllMocks())
it('does not consume queue when template is unconfigured', async () => { config.get.mockReturnValue(''); await service.run(); expect(db.booking.findMany).not.toHaveBeenCalled() })
it('queries only due, unreviewed completed lessons and atomically claims a booking', async () => {
db.booking.updateMany.mockResolvedValue({ count: 1 }); messages.sendReviewReminder.mockResolvedValue(true)
await service.run()
const where = db.booking.findMany.mock.calls[0][0].where
expect(where.status).toBe('COMPLETED'); expect(where.review).toBeNull(); expect(where.reviewReminderDueAt.lte).toBeInstanceOf(Date)
expect(db.booking.updateMany.mock.calls[0][0].where).toEqual({ id: 'b', status: 'COMPLETED', review: null, reviewReminderClaimedAt: null })
expect(db.booking.update).toHaveBeenCalledWith({ where: { id: 'b' }, data: { reviewReminderSentAt: expect.any(Date) } })
})
it('does not send if another worker claimed or member already reviewed', async () => { db.booking.updateMany.mockResolvedValue({ count: 0 }); await service.run(); expect(messages.sendReviewReminder).not.toHaveBeenCalled() })
it('does not mark a failed or unknown send as delivered or automatically release it', async () => { db.booking.updateMany.mockResolvedValue({ count: 1 }); messages.sendReviewReminder.mockRejectedValue(new Error('timeout')); await service.run(); expect(db.booking.update).not.toHaveBeenCalled(); expect(db.booking.updateMany).toHaveBeenCalledTimes(1) })
})

View File

@@ -0,0 +1,107 @@
import { Injectable, Logger } from '@nestjs/common'
import { Cron } from '@nestjs/schedule'
import { ConfigService } from '@nestjs/config'
import { BookingStatus } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
import { SubscriptionMessageService } from '../user/subscription-message.service'
@Injectable()
export class ClassReminderService {
private readonly logger = new Logger(ClassReminderService.name)
constructor(
private readonly prisma: PrismaService,
private readonly messages: SubscriptionMessageService,
private readonly config: ConfigService,
) {}
/**
* Run every 5 minutes to scan for upcoming confirmed classes starting in ~1 hour (50-70 mins).
*/
@Cron('*/5 * * * *')
async run(): Promise<void> {
const templateId = this.messages.getClassReminderTemplateId()
if (!templateId) {
return
}
const now = new Date()
const nowMs = now.getTime()
// Restrict date range to around today to avoid full table scan
const yesterday = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - 1))
const inTwoDays = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 2))
const studio = await this.prisma.studioConfig.findFirst({ select: { name: true } })
const courseName = studio?.name || '普拉提课程'
const rows = await this.prisma.booking.findMany({
where: {
status: BookingStatus.CONFIRMED,
classReminderClaimedAt: null,
classReminderSentAt: null,
timeSlot: {
date: {
gte: yesterday,
lte: inTwoDays,
},
},
},
include: {
user: { select: { openid: true } },
timeSlot: { select: { date: true, startTime: true, endTime: true } },
},
take: 100,
})
for (const row of rows) {
try {
const dateStr = row.timeSlot.date.toISOString().slice(0, 10)
const startTime = row.timeSlot.startTime.slice(0, 5)
const classStartMs = new Date(`${dateStr}T${startTime}:00+08:00`).getTime()
const diffMs = classStartMs - nowMs
// Target: starting in 50 to 70 minutes (around 1 hour ahead)
if (diffMs < 50 * 60 * 1000 || diffMs > 70 * 60 * 1000) {
continue
}
// Atomically claim the booking to prevent race conditions
const claimed = await this.prisma.booking.updateMany({
where: {
id: row.id,
status: BookingStatus.CONFIRMED,
classReminderClaimedAt: null,
},
data: {
classReminderClaimedAt: new Date(),
},
})
if (!claimed.count) {
continue
}
const classTime = `${dateStr} ${startTime}`
const sent = await this.messages.sendClassReminderMessage({
openid: row.user.openid,
userId: row.userId,
bookingId: row.id,
courseName,
classTime,
tips: '课程将于1小时后开始请准时出席',
})
if (sent) {
await this.prisma.booking.update({
where: { id: row.id },
data: { classReminderSentAt: new Date() },
})
this.logger.log(`Class reminder sent successfully for booking ${row.id}`)
}
} catch (error) {
this.logger.error(`Failed to process class reminder for booking ${row.id}`, error)
}
}
}
}

View File

@@ -0,0 +1,25 @@
import { Injectable, Logger } from '@nestjs/common'
import { Cron } from '@nestjs/schedule'
import { ConfigService } from '@nestjs/config'
import { PrismaService } from '../prisma/prisma.service'
import { SubscriptionMessageService } from '../user/subscription-message.service'
@Injectable()
export class ReviewReminderService {
private readonly logger = new Logger(ReviewReminderService.name)
constructor(private readonly prisma: PrismaService, private readonly messages: SubscriptionMessageService, private readonly config: ConfigService) {}
@Cron('*/5 * * * *')
async run() {
const templateId = this.config.get('WX_SUBSCRIBE_TEMPLATE_CLASS_REVIEW')
if (!templateId) return
const rows = await this.prisma.booking.findMany({ where: { status: 'COMPLETED', review: null, reviewReminderDueAt: { lte: new Date(), gte: new Date(Date.now() - 7 * 86400000) }, reviewReminderClaimedAt: null }, include: { user: { select: { openid: true } } }, orderBy: { reviewReminderDueAt: 'asc' }, take: 100 })
for (const row of rows) {
try {
const claimed = await this.prisma.booking.updateMany({ where: { id: row.id, status: 'COMPLETED', review: null, reviewReminderClaimedAt: null }, data: { reviewReminderClaimedAt: new Date() } })
if (!claimed.count) continue
if (await this.messages.sendReviewReminder(row.userId, row.user.openid, row.id)) {
await this.prisma.booking.update({ where: { id: row.id }, data: { reviewReminderSentAt: new Date() } })
}
} catch (error) { this.logger.error(`评价提醒未发送或结果未知: ${row.id}`, error) }
}
}
}

View File

@@ -1,15 +1,18 @@
import { UserModule } from '../user/user.module'
import { ConfigModule } from '@nestjs/config'
import { ReviewReminderService } from './review-reminder.service'
import { ClassReminderService } from './class-reminder.service'
import { Module } from '@nestjs/common' import { Module } from '@nestjs/common'
import { ScheduleModule } from '@nestjs/schedule' import { ScheduleModule } from '@nestjs/schedule'
import { TimeSlotModule } from '../time-slot/time-slot.module' import { TimeSlotModule } from '../time-slot/time-slot.module'
import { FlashSaleModule } from '../flash-sale/flash-sale.module'
import { SchedulerService } from './scheduler.service' import { SchedulerService } from './scheduler.service'
@Module({ @Module({
imports: [ imports: [
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
UserModule, ConfigModule,
TimeSlotModule, TimeSlotModule,
FlashSaleModule,
], ],
providers: [SchedulerService], providers: [SchedulerService, ReviewReminderService, ClassReminderService],
}) })
export class SchedulerModule {} export class SchedulerModule {}

View File

@@ -1,7 +1,6 @@
import { Injectable, Logger } from '@nestjs/common' import { Injectable, Logger } from '@nestjs/common'
import { Cron } from '@nestjs/schedule' import { Cron } from '@nestjs/schedule'
import { SlotGeneratorService } from '../time-slot/slot-generator.service' import { SlotGeneratorService } from '../time-slot/slot-generator.service'
import { FlashSaleService } from '../flash-sale/flash-sale.service'
@Injectable() @Injectable()
export class SchedulerService { export class SchedulerService {
@@ -9,7 +8,6 @@ export class SchedulerService {
constructor( constructor(
private readonly slotGenerator: SlotGeneratorService, private readonly slotGenerator: SlotGeneratorService,
private readonly flashSaleService: FlashSaleService,
) {} ) {}
/** 02:00 daily — generate slots 14 days ahead from week templates */ /** 02:00 daily — generate slots 14 days ahead from week templates */
@@ -55,17 +53,4 @@ export class SchedulerService {
this.logger.error('[handleCompleteBookings] Failed to complete bookings', err) this.logger.error('[handleCompleteBookings] Failed to complete bookings', err)
} }
} }
/** Every 5 min — expire unpaid flash sale reservations older than 15min */
@Cron('*/5 * * * *')
async handleExpireFlashSaleReservations(): Promise<void> {
try {
const count = await this.flashSaleService.expireUnpaidReservations(15)
if (count > 0) {
this.logger.log(`[handleExpireFlashSaleReservations] Expired ${count} unpaid reservations`)
}
} catch (err) {
this.logger.error('[handleExpireFlashSaleReservations] Failed', err)
}
}
} }

View File

@@ -54,10 +54,11 @@ export class StudioUploadService {
} }
} }
private buildPostPolicy(params: { protected buildPostPolicy(params: {
bucket: string bucket: string
key: string key: string
expiresAt: number expiresAt: number
privateRead?: boolean
}): Record<string, string> { }): Record<string, string> {
const secretId = this.getRequiredConfig('COS_SECRET_ID') const secretId = this.getRequiredConfig('COS_SECRET_ID')
const secretKey = this.getRequiredConfig('COS_SECRET_KEY') const secretKey = this.getRequiredConfig('COS_SECRET_KEY')
@@ -66,6 +67,7 @@ export class StudioUploadService {
expiration: new Date(params.expiresAt * 1000).toISOString(), expiration: new Date(params.expiresAt * 1000).toISOString(),
conditions: [ conditions: [
{ bucket: params.bucket }, { bucket: params.bucket },
...(params.privateRead ? [{ 'x-cos-acl': 'private' }] : []),
['eq', '$key', params.key], ['eq', '$key', params.key],
{ success_action_status: '200' }, { success_action_status: '200' },
{ 'q-sign-algorithm': 'sha1' }, { 'q-sign-algorithm': 'sha1' },
@@ -87,6 +89,7 @@ export class StudioUploadService {
return { return {
key: params.key, key: params.key,
...(params.privateRead ? { 'x-cos-acl': 'private' } : {}),
policy: policyBase64, policy: policyBase64,
success_action_status: '200', success_action_status: '200',
'q-sign-algorithm': 'sha1', 'q-sign-algorithm': 'sha1',
@@ -121,7 +124,7 @@ export class StudioUploadService {
return `${startTime};${expiresAt}` return `${startTime};${expiresAt}`
} }
private resolveExtension(fileName: string, contentType?: string): string { protected resolveExtension(fileName: string, contentType?: string): string {
const cleanedName = fileName.trim().toLowerCase() const cleanedName = fileName.trim().toLowerCase()
const fileExtension = cleanedName.includes('.') const fileExtension = cleanedName.includes('.')
? cleanedName.split('.').pop() ?? '' ? cleanedName.split('.').pop() ?? ''
@@ -158,7 +161,7 @@ export class StudioUploadService {
.replace(/^\/+|\/+$/g, '') .replace(/^\/+|\/+$/g, '')
} }
private getRequiredConfig(key: string): string { protected getRequiredConfig(key: string): string {
const value = this.configService.get<string>(key)?.trim() const value = this.configService.get<string>(key)?.trim()
if (!value) { if (!value) {

View File

@@ -217,7 +217,7 @@ describe('SlotGeneratorService', () => {
where: expect.objectContaining({ where: expect.objectContaining({
status: BookingStatus.CONFIRMED, status: BookingStatus.CONFIRMED,
}), }),
data: { status: BookingStatus.COMPLETED }, data: { status: BookingStatus.COMPLETED, completedAt: expect.any(Date), reviewReminderDueAt: expect.any(Date) },
}), }),
) )
}) })

View File

@@ -144,7 +144,7 @@ export class SlotGeneratorService {
date: { lt: today }, date: { lt: today },
}, },
}, },
data: { status: BookingStatus.COMPLETED }, data: { status: BookingStatus.COMPLETED, completedAt: new Date(), reviewReminderDueAt: new Date(Date.now() + 24 * 3600000) },
}) })
this.logger.log(`Completed ${result.count} past bookings`) this.logger.log(`Completed ${result.count} past bookings`)

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