27 Commits

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 15:55:48 +08:00
richarjiang
c3e46f7ffa fix(teaching-schedule): 当日课表展示所有状态,并支持点入课程详情
- 后端 getTeachingScheduleByDate 在查询当天时不再以 PENDING/CONFIRMED 过滤 timeSlot
  也不限制 include.bookings 的状态,但仍保留 EXISTS 守卫过滤空 slot
- 其他日期维持原「只看待上课」语义
- 抽出私有 formatLocalDate 工具方法,消除服务端 YYYY-MM-DD 构造的 3 处重复
- 前端 teaching-schedule 的 session 卡片可点击跳到 /pages/booking/detail?slotId=
- 补全 student__status--completed / --cancelled 样式
2026-09-09 14:51:06 +08:00
richarjiang
75ef5e94a6 refactor: 调整 admin 目录结构并精简个人中心快捷区
- 将 admin 相关的 store/utils 移入对应子目录(pages/admin/stores、pages/admin/utils)
- 更新 manifest.json、pages.json 路由与配置
- 个人中心 ProfileMenu 移除顶部「我的会员卡 / 我的预约」快捷卡片,与 UserCard 会员卡入口合并
- 「我的预约」下移至菜单列表,紧邻「个人信息」
- 清理 profile/index.vue 中不再使用的 bookingStore / 计算属性 / 透传
2026-09-09 14:36:34 +08:00
richarjiang
57107c02dc perf: 优化个人中心会员卡进度展示 2026-09-09 11:27:03 +08:00
richarjiang
726c65f0f0 perf: 会员卡页面支持分享 2026-09-09 10:28:11 +08:00
richarjiang
6e8fc45138 fix: 修复默认启动入口 2026-09-09 10:26:16 +08:00
richarjiang
fcd9531b3c 0.0.2 2026-09-09 10:11:07 +08:00
richarjiang
f4cee127ce feat: 优化个人中心邀请卡片 2026-09-09 10:10:52 +08:00
richarjiang
99c5c211ad feat: 支持上课统计功能;优化会员管理默认筛选状态 2026-09-09 10:04:29 +08:00
richarjiang
30ebc1c344 feat: 支持邀请好友功能 2026-09-08 22:31:39 +08:00
richarjiang
5a4d2c7a1b perf: 优化 预约详情 UI 2026-09-08 17:47:38 +08:00
162 changed files with 10804 additions and 4603 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`
- 会员管理筛选使用特殊值 `NONE` 表示无卡/无有效会员(不在枚举中)
- 会员管理筛选使用特殊值 `ACTIVE` 表示持有 ACTIVE 状态会员卡的会员用户(页面默认),`NONE` 表示无卡/无有效会员(两者不在卡种枚举中)
- 前端选项硬编码在 `src/pages/admin/members.vue``cardTypeOptions`,需与枚举保持同步
### 管理后台 API 模式
- `/admin/members` 支持 `page`, `limit`, `search`, `cardType` 参数
- `cardType=NONE` → 无有效会员的用户;其他值对应 `CardTypeCategory`
- `cardType=ACTIVE` → 持有任意 ACTIVE 状态会员卡;`cardType=NONE` → 无 ACTIVE 状态会员卡;省略参数查看全部用户;其他值对应 `CardTypeCategory`
- 预约统计total/completed/cancelled通过 `groupBy` 批量查询
### 筛选组件模式
@@ -91,3 +91,32 @@ pnpm deploy:server # 部署后端到生产环境
- 补录不创建预约或时段;只增加累计已完成节数,不推测上课日期、天数、时长,不参与月度统计、活跃网格或邀请奖励。
- 可选择从本人有限次会员卡扣次;补录与扣次必须事务提交,保存实际扣次快照,撤销只返还实际扣次。请求标识用于幂等重试。
- Prisma 迁移按 `YYYYMMDDHHmmss_description/migration.sql` 存放;补录表采用增量迁移,回退说明维护在 `docs/lesson-supplement.md`,不删除审计记录。
### 月度教学统计
- 统计归属 admin 模块,服务放 `admin/teaching-analytics.service.ts`,测试放 `admin/__tests__`;共享契约放 `shared/src/types/teaching-analytics.ts`,页面为 `pages/admin/analytics.vue`
- 按 TimeSlot.date 所属自然月查询,不按预约创建或核销日期;日期列以 UTC 日历值读取,中国时间用于判断课程结束。
- 当前无老师归属字段统计范围是工作室operatorId 不是授课老师。COMPLETED 是系统完成状态,不代表签到。
- 已上课程按时段去重,时长按已完成时段累加;上课人次按 COMPLETED 预约计数,学员按 userId 去重。取消、未出席、待确认、已确认独立计数。无日期补录不参与。
- 月历、学员排行和会员卡分布统计已完成记录;明细可组合日期、学员、状态筛选。新增测试目录只放该服务的 *.spec.ts。
### 个人中心会员卡包
- 个人中心资料区域以单行展示持有卡种和张数,下方每张卡以单行浅色进度槽承载卡名和用量文字,不使用大卡片或轮播;点击进入我的会员卡。`OwnedMembershipCard.vue` 在我的会员卡页面展示余额、已用进度和到期日。
- 累计上课、本月上课、剩余课时集中在我的会员卡页面,个人资料卡不重复展示汇总。
- 次数限制以 remainingTimes 是否为 null 判断,不能按卡种推断。次数进度表示已用占总次数,已用包括预约占用;不限次卡不伪造耗课数。
- 会员卡加载失败显示重试,不当作无卡;会话变化时丢弃旧请求结果。
### 课后评价与成长档案
- 评价归属 booking 模块,档案归属 user 模块;共享契约放 shared/src/types/member-care.ts页面沿用 booking/profile/admin 目录,不新增顶层业务目录。
- COMPLETED 后立即可评价,无 24 小时截止;完成后 24 小时仅提醒未评价预约。唯一 bookingId 防重复,提醒状态保存在 Booking 上,定时任务原子领取,未知发送结果不自动重发。
- 星级均分与 NPS 分开NPS 仅使用可选 010 推荐意愿910 推荐者、06 贬损者),按中国自然月聚合并展示样本数。
- 教练私密笔记必须在服务端过滤;课程批注必须属于该学员的已完成预约。体测允许缺项,不以缺项当 0累计课时包含有效补录里程碑为 10/30/50 节。
- 照片仅用于学员与馆主之间的档案展示,不能用于公开宣传。学员本人按照片授权/撤回,馆主不能代授权。与馆图共用 COS 桶,对象前缀 `progress/`,上传为私有 ACL读取用短时签名禁止落库公共链接上传凭证绑定学员及照片记录。
- 新增迁移目录只放 migration.sql测试沿用各模块 __tests__;部署配置与验收清单放 docs/member-care.md。
- 成长档案的两端共用 `components/MemberProgress.vue`,仅此跨端组件直接请求 progress API避免主包引用 admin 分包 Store馆主评价页面仍通过 admin Store 访问。
### 个人身体画像
- 线上获客归属独立 `body-portrait` 模块,不是成长档案的一个页面。共享契约在 `packages/shared/src/types/body-portrait.ts`,规格在 `docs/body-portrait.md`
- 评分只在服务端规则引擎计算;匿名测评用访问令牌哈希,不用可枚举 ID 做权限。
- 安全分流不计分。完整报告需登录认领,体验预约需手机号。现有 TRIAL 体验卡承接转化。
- 馆主「今日经营助手」只展示待办,不做成通用 CRM。主包不得引用 admin store。

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

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

View File

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

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

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

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"
]
},
"version": "0.0.1"
"version": "0.0.2"
}

View File

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

View File

@@ -96,6 +96,11 @@
</text>
</view>
<!-- Subscribe note -->
<view class="subscribe-tip">
<text class="subscribe-tip-text">🔔 确认预约将同步订阅约课结果课前1小时提醒与取消通知</text>
</view>
<!-- Action buttons -->
<view class="action-row">
<view class="btn-outline" @tap="handleCancel">
@@ -159,9 +164,7 @@ async function handleConfirm() {
try {
await requestBookingCreatedSubscriptionMessage()
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '订阅消息授权失败'
uni.showToast({ title: message, icon: 'none' })
return
console.warn('[subscribe] booking confirm failed', err)
} finally {
requestingSubscribe.value = false
}
@@ -212,6 +215,8 @@ function handleMaskTap() {
.no-card-text { font-size: 24rpx; color: #8b817b; }
.deduction-tip { padding: 18rpx 4rpx; }
.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; }
.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; }

View File

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

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

View File

@@ -0,0 +1,69 @@
<template>
<canvas canvas-id="portraitRadar" class="radar" />
</template>
<script setup lang="ts">
import { getCurrentInstance, onMounted, watch } from 'vue'
import { BODY_DIMENSION_LABELS, type BodyPortraitScores } from '@mp-pilates/shared'
const props = defineProps<{ scores: BodyPortraitScores }>()
const instance = getCurrentInstance()
const labels = ['肩颈', '脊柱', '核心', '髋骨盆', '下肢']
const keys = ['cervicalShoulder', 'spinalMobility', 'coreControl', 'hipPelvis', 'lowerLimb'] as const
function draw() {
const ctx = uni.createCanvasContext('portraitRadar', instance?.proxy)
const w = 280
const h = 280
const cx = w / 2
const cy = h / 2
const radius = 96
ctx.clearRect(0, 0, w, h)
ctx.setStrokeStyle('#d9cfc5')
ctx.setFillStyle('#edf2ec')
for (let ring = 1; ring <= 4; ring++) {
ctx.beginPath()
for (let i = 0; i < 5; i++) {
const angle = -Math.PI / 2 + (Math.PI * 2 * i) / 5
const x = cx + Math.cos(angle) * radius * (ring / 4)
const y = cy + Math.sin(angle) * radius * (ring / 4)
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
}
ctx.closePath()
ctx.stroke()
}
ctx.setFillStyle('rgba(107,130,118,0.35)')
ctx.setStrokeStyle('#6b8276')
ctx.beginPath()
keys.forEach((key, i) => {
const angle = -Math.PI / 2 + (Math.PI * 2 * i) / 5
const value = Math.max(8, props.scores[key]) / 100
const x = cx + Math.cos(angle) * radius * value
const y = cy + Math.sin(angle) * radius * value
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
})
ctx.closePath()
ctx.fill()
ctx.stroke()
ctx.setFillStyle('#5d5148')
ctx.setFontSize(11)
ctx.setTextAlign('center')
labels.forEach((label, i) => {
const angle = -Math.PI / 2 + (Math.PI * 2 * i) / 5
const x = cx + Math.cos(angle) * (radius + 22)
const y = cy + Math.sin(angle) * (radius + 22)
ctx.fillText(label, x, y)
})
ctx.draw()
}
onMounted(draw)
watch(() => props.scores, draw, { deep: true })
void BODY_DIMENSION_LABELS
</script>
<style lang="scss" scoped>
.radar { width: 560rpx; height: 560rpx; margin: 0 auto; }
</style>

View File

@@ -1,21 +1,9 @@
<template>
<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 />
<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-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)">
@@ -36,49 +24,26 @@ interface MenuItem {
title?: string
path?: string
isAdmin?: boolean
badge?: string
action?: 'clear'
action?: 'clear' | 'notifications'
requireAuth?: boolean
}
const props = defineProps<{
isAdmin: boolean
requireAuth?: boolean
activeMembershipCount?: number
upcomingBookingCount?: number
inviteShareEligible?: boolean
}>()
const emit = defineEmits<{
(e: 'clear-cache'): void
(e: 'require-login'): void
(e: 'open-notifications'): void
}>()
const menuItems = computed<MenuItem[]>(() => {
const membershipBadge = props.activeMembershipCount && props.activeMembershipCount > 0
? `${props.activeMembershipCount}`
: undefined
const bookingBadge = props.upcomingBookingCount && props.upcomingBookingCount > 0
? `${props.upcomingBookingCount}`
: undefined
const items: MenuItem[] = [
{
key: 'membership',
type: 'item',
title: '我的会员卡',
path: '/pages/profile/membership',
badge: membershipBadge,
requireAuth: true,
},
{
key: 'bookings',
type: 'item',
title: '我的预约',
path: '/pages/profile/bookings',
badge: bookingBadge,
requireAuth: true,
},
{ key: 'progress', type: 'item', title: '我的成长档案', path: '/pages/profile/progress', requireAuth: true },
{ key: 'portrait', type: 'item', title: '身体状态评估', path: '/pages/portrait/index' },
{ key: 'plan', type: 'item', title: '我的改善计划', path: '/pages/portrait/plan', requireAuth: true },
...(props.isAdmin
? [{
key: 'teaching-schedule',
@@ -88,16 +53,13 @@ const menuItems = computed<MenuItem[]>(() => {
requireAuth: true,
}]
: []),
// 临时隐藏邀请好友入口,后续恢复时直接取消这段注释即可。
// ...(props.inviteShareEligible
// ? [{
// key: 'invite',
// type: 'item' as const,
// title: '邀请好友',
// path: '/pages/profile/invite',
// requireAuth: true,
// }]
// : []),
{
key: 'bookings',
type: 'item',
title: '我的预约',
path: '/pages/profile/bookings',
requireAuth: true,
},
{
key: 'info',
type: 'item',
@@ -105,6 +67,13 @@ const menuItems = computed<MenuItem[]>(() => {
path: '/pages/profile/info',
requireAuth: true,
},
{
key: 'notifications',
type: 'item',
title: '消息提醒设置',
action: 'notifications',
requireAuth: true,
},
{
key: 'sep1',
type: 'separator',
@@ -132,9 +101,6 @@ const menuItems = computed<MenuItem[]>(() => {
return items
})
const primaryItems = computed(() => menuItems.value.filter(item => item.key === 'membership' || item.key === 'bookings'))
const secondaryItems = computed(() => menuItems.value.filter(item => item.key !== 'membership' && item.key !== 'bookings'))
function handleTap(item: MenuItem) {
if (item.requireAuth && !props.requireAuth) {
emit('require-login')
@@ -142,6 +108,8 @@ function handleTap(item: MenuItem) {
}
if (item.action === 'clear') {
emit('clear-cache')
} else if (item.action === 'notifications') {
emit('open-notifications')
} else if (item.path) {
uni.navigateTo({ url: item.path })
}
@@ -150,14 +118,6 @@ function handleTap(item: MenuItem) {
<style lang="scss" scoped>
.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; }
&__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; }

View File

@@ -1,27 +1,14 @@
<template>
<view class="quick-entry">
<!-- Not logged in -->
<view v-if="!userStore.loggedIn" class="entry-pill pill-login" @tap="handleLogin">
<text class="pill-label">欢迎来到工作室</text>
<view v-if="!userStore.loggedIn || userStore.memberships.length === 0" class="entry-pill pill-login" @tap="handlePortrait">
<text class="pill-label">3 分钟了解自己的身体状态</text>
<view class="pill-action action-login">
<text class="pill-action-text">微信登录</text>
<text class="pill-action-text">开始评估</text>
</view>
</view>
<!-- Logged in, no memberships new user -->
<view
v-else-if="userStore.loggedIn && userStore.memberships.length === 0"
class="entry-pill pill-trial"
@tap="handleTrialEntry"
>
<text class="pill-label">首次体验专属课程</text>
<view class="pill-action action-trial">
<text class="pill-action-text">预约体验课</text>
</view>
</view>
<!-- Has valid active card -->
<!-- Has valid active card -->
<template v-else-if="userStore.hasValidMembership">
<view class="entry-pill pill-active" @tap="handleBooking">
<text class="pill-label pill-label-active">{{ activeMembershipLabel }}</text>
@@ -75,6 +62,10 @@ async function handleLogin() {
}
}
function handlePortrait() {
uni.navigateTo({ url: '/pages/portrait/index' })
}
function handleTrialEntry() {
uni.navigateTo({ url: '/pages/card/detail?trial=1' })
}

View File

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

View File

@@ -0,0 +1,16 @@
<template>
<view class="notice">
<text class="title">建议先确认运动条件</text>
<text class="body">{{ message }}</text>
</view>
</template>
<script setup lang="ts">
defineProps<{ message: string }>()
</script>
<style lang="scss" scoped>
.notice { margin: 24rpx 0; padding: 28rpx; border-radius: 20rpx; background: #f7eee8; }
.title { display: block; font-size: 30rpx; color: #7a4e3e; margin-bottom: 12rpx; }
.body { display: block; font-size: 26rpx; line-height: 1.7; color: #8a6458; }
</style>

View File

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

View File

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

View File

@@ -12,13 +12,19 @@
{
"path": "pages/booking/index",
"style": {
"navigationStyle": "custom"
"navigationStyle": "custom",
"componentPlaceholder": {
"booking-confirm-popup": "view"
}
}
},
{
"path": "pages/booking/detail",
"style": {
"navigationStyle": "custom"
"navigationStyle": "custom",
"componentPlaceholder": {
"booking-confirm-popup": "view"
}
}
},
{
@@ -70,86 +76,179 @@
}
},
{
"path": "pages/admin/index",
"path": "pages/profile/progress",
"style": {
"navigationStyle": "custom"
}
}
],
"subPackages": [
{
"root": "pages/admin",
"pages": [
{
"path": "index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/admin/bookings",
"path": "analytics",
"style": {
"navigationStyle": "custom",
"enablePullDownRefresh": true
}
},
{
"path": "bookings",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/admin/schedule",
"path": "schedule",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/admin/slot-adjust",
"path": "slot-adjust",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/admin/members",
"path": "members",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/admin/member-detail",
"path": "member-detail",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/admin/member-edit",
"path": "member-edit",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/admin/member-supplement",
"style": { "navigationStyle": "custom" }
},
{
"path": "pages/admin/member-arrange",
"path": "member-supplement",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/admin/orders",
"path": "member-arrange",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/admin/card-types",
"path": "orders",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/admin/studio",
"path": "card-types",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/admin/flash-sales",
"path": "studio",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/flash-sale/detail",
"path": "member-progress",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "reviews",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "portrait-today",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "portrait-leads",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "portrait-lead-detail",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "portrait-assessment",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "portrait-plan",
"style": {
"navigationStyle": "custom"
}
}
]
},
{
"root": "pages/portrait",
"pages": [
{
"path": "index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "assessment",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "report",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "advice",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "trial",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "plan",
"style": {
"navigationStyle": "custom"
}
}
]
}
],
"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 CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from '../../stores/admin'
import { useAdminStore } from './stores/admin'
import { formatPrice } from '../../utils/format'
import { uploadStudioAsset } from '../../utils/studio-upload'
import { uploadStudioAsset } from './utils/studio-upload'
import { CardTypeCategory } 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,85 @@
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="管理中心" show-back />
<!-- Stats summary card -->
<view class="stats-card-wrap">
<view class="stats-card">
<view v-if="statsLoading" class="stats-loading">
<view v-for="i in 3" :key="i" class="stat-skeleton" />
</view>
<template v-else>
<view class="stat-block">
<text class="stat-num">{{ stats.todayBookings }}</text>
<text class="stat-sub">今日预约</text>
</view>
<view class="stat-sep" />
<view class="stat-block">
<text class="stat-num">{{ stats.totalOrders }}</text>
<text class="stat-sub">总订单</text>
</view>
<view class="stat-sep" />
<view class="stat-block">
<text class="stat-num">{{ stats.totalBookings }}</text>
<text class="stat-sub">总预约</text>
</view>
</template>
</view>
</view>
<!-- Section header: 课程管理 -->
<!-- Section: 课务运营 -->
<view class="section-header">
<text class="section-title">课程管理</text>
<text class="section-title">今日经营</text>
</view>
<!-- List: schedule -->
<view class="list">
<view class="list-item" @tap="navigate('/pages/admin/bookings')">
<view class="item-left">
<view class="item-icon-wrap icon--bookings">
<text class="item-icon-text"></text>
<view class="list-item" @tap="navigate('/pages/admin/portrait-today')">
<text class="item-title">今日经营助手</text>
<text class="arrow-text"></text>
</view>
<view class="item-text-group">
<text class="item-title">预约管理</text>
<text class="item-desc">查看/确认/核销学员预约</text>
</view>
</view>
<view class="item-arrow">
<view class="list-item" @tap="navigate('/pages/admin/portrait-leads')">
<text class="item-title">身体画像线索</text>
<text class="arrow-text"></text>
</view>
</view>
<!-- Section: 课务运营 -->
<view class="section-header">
<text class="section-title">课务运营</text>
</view>
<view class="list">
<view class="list-item" @tap="navigate('/pages/admin/schedule')">
<view class="item-left">
<view class="item-icon-wrap icon--schedule">
<text class="item-icon-text"></text>
</view>
<view class="item-text-group">
<text class="item-title">排课管理</text>
<text class="item-desc">管理每周课程时段</text>
<text class="arrow-text"></text>
</view>
</view>
<view class="item-arrow">
<view class="list-item" @tap="navigate('/pages/admin/bookings')">
<text class="item-title">预约管理</text>
<text class="arrow-text"></text>
</view>
</view>
<!-- Section: 教学复盘 -->
<view class="section-header">
<text class="section-title">教学复盘</text>
</view>
<view class="list">
<view class="list-item" @tap="navigate('/pages/admin/analytics')">
<text class="item-title">统计分析</text>
<text class="arrow-text"></text>
</view>
<view class="list-item" @tap="navigate('/pages/admin/reviews')">
<text class="item-title">课后评价</text>
<text class="arrow-text"></text>
</view>
</view>
<!-- Section header: 会员与订单 -->
<!-- Section: 会员与订单 -->
<view class="section-header">
<text class="section-title">会员与订单</text>
</view>
<!-- List: members & orders -->
<view class="list">
<view class="list-item" @tap="navigate('/pages/admin/members')">
<view class="item-left">
<view class="item-icon-wrap icon--members">
<text class="item-icon-text"></text>
</view>
<view class="item-text-group">
<text class="item-title">会员管理</text>
<text class="item-desc">查看所有会员信息</text>
</view>
</view>
<view class="item-arrow">
<text class="arrow-text"></text>
</view>
</view>
<view class="list-item" @tap="navigate('/pages/admin/orders')">
<view class="item-left">
<view class="item-icon-wrap icon--orders">
<text class="item-icon-text"></text>
</view>
<view class="item-text-group">
<text class="item-title">订单管理</text>
<text class="item-desc">查看所有订单记录</text>
</view>
</view>
<view class="item-arrow">
<text class="arrow-text"></text>
</view>
</view>
<view class="list-item" @tap="navigate('/pages/admin/card-types')">
<view class="item-left">
<view class="item-icon-wrap icon--card">
<text class="item-icon-text"></text>
</view>
<view class="item-text-group">
<text class="item-title">卡种管理</text>
<text class="item-desc">设置会员卡类型</text>
</view>
</view>
<view class="item-arrow">
<text class="arrow-text"></text>
</view>
</view>
<view class="list-item" @tap="navigate('/pages/admin/flash-sales')">
<view class="item-left">
<view class="item-icon-wrap icon--flash-sale">
<text class="item-icon-text"></text>
</view>
<view class="item-text-group">
<text class="item-title">秒杀管理</text>
<text class="item-desc">创建和管理限时秒杀活动</text>
</view>
</view>
<view class="item-arrow">
<text class="arrow-text"></text>
</view>
</view>
</view>
<!-- Section header: 系统 -->
<!-- Section: 系统设置 -->
<view class="section-header">
<text class="section-title">系统</text>
<text class="section-title">系统设置</text>
</view>
<!-- List: settings -->
<view class="list">
<view class="list-item" @tap="navigate('/pages/admin/studio')">
<view class="item-left">
<view class="item-icon-wrap icon--studio">
<text class="item-icon-text"></text>
</view>
<view class="item-text-group">
<text class="item-title">工作室设置</text>
<text class="item-desc">工作室信息与配置</text>
</view>
</view>
<view class="item-arrow">
<view class="list-item" @tap="navigate('/pages/admin/card-types')">
<text class="item-title">卡种管理</text>
<text class="arrow-text"></text>
</view>
<view class="list-item" @tap="navigate('/pages/admin/studio')">
<text class="item-title">工作室设置</text>
<text class="arrow-text"></text>
</view>
<view class="list-item" @tap="handleIncreaseSubscriptionCount">
<view class="item-left">
<view class="item-icon-wrap icon--subscribe">
<text class="item-icon-text"></text>
</view>
<view class="item-text-group">
<text class="item-title">增加订阅次数</text>
<text class="item-desc">当前剩余 {{ user?.adminBookingSubscriptionCount ?? 0 }} </text>
</view>
</view>
<view class="item-arrow">
<text class="item-extra">剩余 {{ user?.adminBookingSubscriptionCount ?? 0 }} </text>
<text class="arrow-text">{{ adminSubscribeLoading ? '...' : '' }}</text>
</view>
</view>
</view>
<view style="height: 40rpx" />
</view>
@@ -180,37 +91,21 @@ import { ref, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from '../../stores/admin'
import { useUserStore } from '../../stores/user'
import type { AdminStats } from '../../stores/admin'
import { requestAdminBookingSubscriptionCount } from '../../utils/wechat-subscription'
import { getErrorMessage } from '../../utils/auth'
const navBarHeight = ref('64px')
const adminStore = useAdminStore()
const userStore = useUserStore()
const { user } = storeToRefs(userStore)
const statsLoading = ref(false)
const stats = ref<AdminStats>({ todayBookings: 0, totalOrders: 0, totalBookings: 0 })
const adminSubscribeLoading = ref(false)
function navigate(path: string) {
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() {
if (adminSubscribeLoading.value) {
return
@@ -235,78 +130,18 @@ async function handleIncreaseSubscriptionCount() {
onMounted(() => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
loadStats()
userStore.fetchProfile()
})
</script>
<style lang="scss" scoped>
/* ── Page ───────────────────────────────────── */
/* ── Page ───────────────────────── */
.page {
min-height: 100vh;
padding-bottom: 40rpx;
}
/* ── Stats card ─────────────────────────────── */
.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;
}
@@ -319,7 +154,7 @@ onMounted(() => {
text-transform: uppercase;
}
/* ── List ───────────────────────────────────── */
/* ── List ───────────────────────── */
.list {
background: #FFFFFF;
margin: 0 24rpx;
@@ -332,8 +167,8 @@ onMounted(() => {
.list-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28rpx 24rpx;
gap: 16rpx;
padding: 32rpx 28rpx;
border-bottom: 1rpx solid rgba(180, 160, 130, 0.1);
transition: background 0.15s ease;
@@ -346,64 +181,23 @@ onMounted(() => {
}
}
.item-left {
display: flex;
align-items: center;
gap: 20rpx;
}
.item-icon-wrap {
width: 72rpx;
height: 72rpx;
border-radius: 18rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.item-icon-text {
font-size: 32rpx;
color: #FFFFFF;
line-height: 1;
}
/* Icon variants — warm muted tones */
.icon--bookings { background: linear-gradient(135deg, #C4A87E, #B49868); }
.icon--schedule { background: linear-gradient(135deg, #8B9E7E, #7A8E6E); }
.icon--template { background: linear-gradient(135deg, #A090C0, #9080B0); }
.icon--members { background: linear-gradient(135deg, $primary-color, $primary-dark); }
.icon--orders { background: linear-gradient(135deg, #7E9EC4, #6E8EB4); }
.icon--card { background: linear-gradient(135deg, #C48E7E, #B47E6E); }
.icon--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 {
flex: 1;
font-size: 30rpx;
font-weight: 600;
font-weight: 500;
color: #4A4035;
letter-spacing: 0.5rpx;
}
.item-desc {
.item-extra {
font-size: 24rpx;
color: #A09080;
}
.item-arrow {
flex-shrink: 0;
padding-left: 16rpx;
}
.arrow-text {
font-size: 40rpx;
flex-shrink: 0;
font-size: 36rpx;
color: rgba(180, 160, 130, 0.5);
font-weight: 300;
line-height: 1;

View File

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

View File

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

View File

@@ -111,7 +111,7 @@ import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { formatDateLocal } from '../../utils/format'
import { getErrorMessage } from '../../utils/auth'
import { useAdminStore } from '../../stores/admin'
import { useAdminStore } from './stores/admin'
const adminStore = useAdminStore()
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 CustomNavBar from '../../components/CustomNavBar.vue'
import LessonSupplementList from '../../components/LessonSupplementList.vue'
import { useAdminStore } from '../../stores/admin'
import { useAdminStore } from './stores/admin'
import { getSystemLayout } from '../../utils/system'
import { HttpRequestError } from '../../utils/request'
import { getErrorMessage } from '../../utils/auth'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -160,9 +160,10 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import type { ScheduleSlotPreview } from '@mp-pilates/shared'
import { TimeSlotStatus } from '@mp-pilates/shared'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from '../../stores/admin'
import { useAdminStore } from './stores/admin'
import { formatDate } from '../../utils/format'
import DateSelector from '../../components/DateSelector.vue'
import {
@@ -170,7 +171,7 @@ import {
timeToPickerIndex,
pickerIndexToTime,
addOneHourCapped,
} from '../../utils/schedule-time'
} from './utils/schedule-time'
const timePickerRange = SCHEDULE_TIME_PICKER_RANGE
@@ -181,6 +182,7 @@ interface EditableSlot {
endTime: string
capacity: number
bookedCount: number
status: TimeSlotStatus
isPublished: boolean
isNew: boolean
isRemoved: boolean
@@ -222,6 +224,7 @@ function mapPreviewToEditable(previews: readonly ScheduleSlotPreview[]): Editabl
endTime: p.endTime,
capacity: p.capacity,
bookedCount: p.bookedCount,
status: (p.status ?? TimeSlotStatus.OPEN) as TimeSlotStatus,
isPublished: p.isPublished,
isNew: false,
isRemoved: false,
@@ -323,6 +326,7 @@ function submitAdd() {
endTime: addForm.value.endTime,
capacity,
bookedCount: 0,
status: TimeSlotStatus.OPEN,
isPublished: false,
isNew: true,
isRemoved: false,
@@ -396,18 +400,21 @@ async function doPublish(slots: readonly EditableSlot[]) {
// ── Style helpers ─────────────────────────────────────────
function slotCardClass(slot: EditableSlot): string {
if (slot.status === TimeSlotStatus.CLOSED) return 'slot-card--closed'
if (slot.isNew) return 'slot-card--new'
if (slot.isPublished) return 'slot-card--published'
return 'slot-card--template'
}
function slotBadgeClass(slot: EditableSlot): string {
if (slot.status === TimeSlotStatus.CLOSED) return 'badge--closed'
if (slot.isNew) return 'badge--new'
if (slot.isPublished) return 'badge--published'
return 'badge--template'
}
function slotBadgeText(slot: EditableSlot): string {
if (slot.status === TimeSlotStatus.CLOSED) return '已关闭'
if (slot.isNew) return '新增'
if (slot.isPublished) return '已发布'
return '默认时段'
@@ -495,6 +502,12 @@ onMounted(() => {
border-color: #3498db;
background: rgba(52, 152, 219, 0.04);
}
&--closed {
opacity: 0.55;
background: #fafafa;
border-color: #e5e5e5;
}
}
/* ── Slot header ─────────────────────────── */
@@ -516,6 +529,8 @@ onMounted(() => {
.badge--template .slot-badge-text { font-size: 22rpx; color: #b8860b; font-weight: 600; }
.badge--new { background: rgba(52, 152, 219, 0.1); }
.badge--new .slot-badge-text { font-size: 22rpx; color: #3498db; font-weight: 600; }
.badge--closed { background: rgba(0, 0, 0, 0.06); }
.badge--closed .slot-badge-text { font-size: 22rpx; color: #888; font-weight: 600; }
.booked-info { }
.booked-text { font-size: 22rpx; color: #e67e22; }

View File

@@ -152,14 +152,14 @@
import { ref, onMounted } from 'vue'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from '../../stores/admin'
import { useAdminStore } from './stores/admin'
import { formatDate } from '../../utils/format'
import type { TimeSlot } from '@mp-pilates/shared'
import {
SCHEDULE_TIME_PICKER_RANGE,
timeToPickerIndex,
pickerIndexToTime,
} from '../../utils/schedule-time'
} from './utils/schedule-time'
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 { ref } from 'vue'
import { get, post, put, del } from '../utils/request'
import { get, post, put, del } from '../../../utils/request'
import type {
TeachingAnalytics,
CardType,
CreateCardTypeDto,
UpdateCardTypeDto,
@@ -13,9 +15,6 @@ import type {
PaginatedData,
ScheduleSlotPreview,
PublishDaySlotsDto,
FlashSaleAdminItem,
CreateFlashSaleDto,
UpdateFlashSaleDto,
CreateStudioUploadCredentialDto,
StudioUploadCredential,
AdminMemberSummary,
@@ -26,6 +25,13 @@ import type {
AdminArrangeBookingDto,
MembershipWithCardType,
BookingWithDetails,
GrowthTodayDashboard,
GrowthLeadSummary,
GrowthLeadDetail,
ProfessionalAssessmentSessionRecord,
CreateProfessionalAssessmentDto,
TrainingPlanRecord,
CreateTrainingPlanDto,
} from '@mp-pilates/shared'
interface LegacyPaginatedData<T> {
@@ -55,12 +61,6 @@ function normalizePaginatedData<T>(
}
}
export interface AdminStats {
todayBookings: number
totalOrders: number
totalBookings: number
}
export type MemberSummary = AdminMemberSummary
export interface UserMembership {
@@ -83,6 +83,9 @@ export interface UserMembership {
}
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 ───────────────────────────────────────────────────
const cardTypes = ref<CardType[]>([])
@@ -262,32 +265,40 @@ export const useAdminStore = defineStore('admin', () => {
await fetchSchedulePreview(dto.date)
}
// ── Dashboard stats ──────────────────────────────────────────────
async function fetchDashboardStats(): Promise<AdminStats> {
return get<AdminStats>('/admin/stats')
// ── Teaching analytics ─────────────────────────────────────────
async function fetchTeachingAnalytics(month: string): Promise<TeachingAnalytics> {
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 fetchGrowthToday() {
return get<GrowthTodayDashboard>('/admin/growth/today')
}
async function createFlashSale(dto: CreateFlashSaleDto): Promise<FlashSaleAdminItem> {
return post<FlashSaleAdminItem>('/admin/flash-sales', dto as unknown as Record<string, unknown>)
async function fetchGrowthLeads(params: { page?: number; search?: string; stage?: string } = {}) {
return get<PaginatedData<GrowthLeadSummary>>('/admin/growth/leads', params)
}
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 fetchGrowthLead(id: string) {
return get<GrowthLeadDetail>(`/admin/growth/leads/${id}`)
}
async function deleteFlashSale(id: string): Promise<{ deleted: boolean }> {
return del<{ deleted: boolean }>(`/admin/flash-sales/${id}`)
async function createProfessionalAssessment(userId: string, dto: CreateProfessionalAssessmentDto) {
return post<ProfessionalAssessmentSessionRecord>(
`/admin/members/${userId}/professional-assessments`,
dto as unknown as Record<string, unknown>,
)
}
async function createTrainingPlan(userId: string, dto: CreateTrainingPlanDto) {
return post<TrainingPlanRecord>(
`/admin/members/${userId}/training-plans`,
dto as unknown as Record<string, unknown>,
)
}
return {
fetchReviews, fetchReviewTrend,
fetchTeachingAnalytics,
// State
cardTypes,
studioConfig,
@@ -326,12 +337,10 @@ export const useAdminStore = defineStore('admin', () => {
fetchSchedulePreview,
previewScheduleByDate,
publishDaySlots,
// Stats
fetchDashboardStats,
// Flash sales
fetchFlashSales,
createFlashSale,
updateFlashSale,
deleteFlashSale,
fetchGrowthToday,
fetchGrowthLeads,
fetchGrowthLead,
createProfessionalAssessment,
createTrainingPlan,
}
})

View File

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

View File

@@ -89,6 +89,16 @@
</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">
<text class="panel-title">上课前</text>
<view v-for="(item, index) in reminderNotes" :key="item" class="note-row">
@@ -179,8 +189,8 @@
</view>
<BookingConfirmPopup
v-if="isSlotMode"
:visible="showConfirmPopup"
v-if="showConfirmPopup"
:visible="true"
:time-slot="slotData"
:memberships="userStore.activeMemberships as MembershipWithCardType[]"
@confirm="onConfirmBooking"
@@ -199,7 +209,7 @@ import type {
TimeSlotWithBookingStatus,
MembershipWithCardType,
} 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 { useUserStore } from '../../stores/user'
import { getErrorMessage } from '../../utils/auth'
@@ -212,8 +222,42 @@ import {
bookingTimelineDotClass,
} from '../../utils/booking-helpers'
import CustomNavBar from '../../components/CustomNavBar.vue'
import ClassReviewForm from '../../components/ClassReviewForm.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 userStore = useUserStore()
@@ -232,6 +276,12 @@ const slotData = ref<TimeSlotWithBookingStatus | null>(null)
const showConfirmPopup = ref(false)
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(() =>
booking.value?.status === BookingStatus.PENDING_CONFIRMATION ||
booking.value?.status === BookingStatus.CONFIRMED,
@@ -584,6 +634,12 @@ async function handleNoShow() {
}
async function handleCancel() {
try {
await requestBookingCancelSubscriptionMessage()
} catch (err: unknown) {
console.warn('[subscribe] cancel pre-subscribe failed', err)
}
uni.showModal({
title: '取消预约',
content: '确定要取消该预约?',
@@ -614,6 +670,7 @@ onMounted(async () => {
})
onLoad((query) => {
void get<SubscriptionMessageTemplateConfig>('/user/subscription-messages/templates').then(cacheSubscriptionMessageTemplateConfig).catch(() => {})
updateLayout()
const q = query as Record<string, string>
@@ -637,6 +694,7 @@ function updateLayout() {
</script>
<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 {
height: 100vh;
box-sizing: border-box;
@@ -1003,7 +1061,8 @@ function updateLayout() {
}
&--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 {
display: flex;
gap: 12rpx;
flex-shrink: 0;
gap: 16rpx;
.dock-btn {
flex: 1 1 0;
min-width: 0;
}
}
.dock--slot .dock-btn {
@@ -1057,9 +1122,13 @@ function updateLayout() {
}
.dock-btn {
flex: 1;
height: 80rpx;
border-radius: 999rpx;
// The dock stacks vertically; only horizontal rows should distribute space.
flex: none;
box-sizing: border-box;
height: 96rpx;
min-height: 48px;
padding: 0 24rpx;
border-radius: 20rpx;
background: #6b8276;
display: flex;
align-items: center;
@@ -1071,7 +1140,7 @@ function updateLayout() {
&--ghost {
background: #fff;
border: 1rpx solid #eee8e3;
border: 1rpx solid #d8cec5;
}
&--danger {

View File

@@ -22,6 +22,8 @@
<scroll-view
class="slot-scroll"
scroll-y
:scroll-into-view="targetSlotId"
scroll-with-animation
refresher-enabled
:refresher-triggered="refreshing"
@refresherrefresh="onRefresh"
@@ -52,15 +54,19 @@
<text class="date-summary-text">{{ filteredSlots.length }} 个时段</text>
</view>
<SlotCard
<view
v-for="item in filteredSlots"
:id="`slot-${item.id}`"
:key="item.id"
>
<SlotCard
:time-slot="item"
@book="onBookTap"
@cancel="onCancelTap"
@card-tap="onSlotCardTap"
/>
</view>
</view>
<!-- Bottom padding spacer -->
<view class="scroll-bottom-spacer" />
@@ -68,7 +74,8 @@
<!-- Confirm popup -->
<BookingConfirmPopup
:visible="showConfirmPopup"
v-if="showConfirmPopup"
:visible="true"
:time-slot="pendingSlot"
:memberships="userStore.activeMemberships as MembershipWithCardType[]"
@confirm="onConfirmBooking"
@@ -78,19 +85,20 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { onResize, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { ref, computed, onMounted, nextTick, getCurrentInstance } from 'vue'
import { onResize, onShareAppMessage, onShareTimeline, onShow } from '@dcloudio/uni-app'
import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pilates/shared'
import { BookingStatus, TIME_PERIODS } from '@mp-pilates/shared'
import { useBookingStore } from '../../stores/booking'
import { useUserStore } from '../../stores/user'
import { getErrorMessage } from '../../utils/auth'
import { formatDate } from '../../utils/format'
import { formatDate, isSlotPast } from '../../utils/format'
import { getSystemLayout } from '../../utils/system'
import DateSelector from '../../components/DateSelector.vue'
import TimePeriodFilter from '../../components/TimePeriodFilter.vue'
import SlotCard from '../../components/SlotCard.vue'
import BookingConfirmPopup from '../../components/BookingConfirmPopup.vue'
import { requestBookingCancelSubscriptionMessage } from '../../utils/wechat-subscription'
type PeriodKey = keyof typeof TIME_PERIODS | null
@@ -104,6 +112,10 @@ const selectedPeriod = ref<PeriodKey>(null)
const showConfirmPopup = ref(false)
const pendingSlot = ref<TimeSlotWithBookingStatus | null>(null)
const refreshing = ref(false)
const targetSlotId = ref('')
// 仅在「每次启动首次进入预约 TAB」时自动定位到当前时段及以后
// 切换日期/时段或下拉刷新后不再重置位置,避免打断用户的浏览位置。
const hasAutoScrolled = ref(false)
// ─── 微信分享 ───────────────────────────────────────────────
onShareAppMessage(() => {
@@ -167,6 +179,94 @@ async function onRefresh() {
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 ───────────────────────────────────────
function onDateSelect(date: string) {
selectedDate.value = date
@@ -269,6 +369,12 @@ async function onConfirmBooking(payload: { timeSlotId: string; membershipId: str
async function onCancelTap(slot: TimeSlotWithBookingStatus) {
if (!slot.myBookingId) return
try {
await requestBookingCancelSubscriptionMessage()
} catch (err: unknown) {
console.warn('[subscribe] cancel pre-subscribe failed', err)
}
uni.showModal({
title: '取消预约',
content: '确定要取消这个预约吗?',
@@ -295,12 +401,21 @@ async function onCancelTap(slot: TimeSlotWithBookingStatus) {
// ─── Lifecycle ────────────────────────────────────────────
onMounted(async () => {
const tasks: Promise<unknown>[] = [loadSlots(selectedDate.value)]
// Load memberships if logged in but not yet fetched
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>

View File

@@ -5,6 +5,26 @@
:style="{ paddingTop: navBarHeight }"
>
<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 class="skeleton-pass" />
@@ -64,10 +84,11 @@
<text class="card-benefit">{{ cardAccessLabel(c) }}</text>
<view class="card-price-row">
<view class="price-stack">
<text class="card-price">¥{{ formatPrice(c.price) }}</text>
<text v-if="getSavingsLabel(c)" class="save-tag">{{ getSavingsLabel(c) }}</text>
<text class="card-price">¥{{ formatPrice(invite.pendingCode && !invite.eligible ? Math.round(c.price * 95 / 100) : invite.price(c.price)) }}</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>
<text class="card-unit">{{ getUnitPriceLabel(c) }}</text>
<text v-if="!invite.pendingCode || invite.eligible" class="card-unit">{{ getUnitPriceLabel(c) }}</text>
</view>
</view>
@@ -129,7 +150,7 @@
<view class="hero-price-row">
<text class="hero-currency">¥</text>
<text class="hero-price">{{ formatPrice(cardData.price) }}</text>
<text class="hero-price">{{ formatPrice(invite.price(cardData.price)) }}</text>
<text
v-if="cardData.originalPrice && cardData.originalPrice > cardData.price"
class="hero-original"
@@ -239,7 +260,7 @@
<view class="bottom-bar" :class="{ 'bottom-bar--renew': isRenewal }">
<view class="price-summary">
<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>
</view>
<button
@@ -270,7 +291,7 @@
<text class="sheet-card-type">{{ typeLabel }}</text>
<text class="sheet-card-name">{{ cardData.name }}</text>
</view>
<text class="sheet-card-price">¥{{ formatPrice(cardData.price) }}</text>
<text class="sheet-card-price">¥{{ formatPrice(invite.price(cardData.price)) }}</text>
</view>
<view class="sheet-card-meta">
<text>{{ cardAccessLabel(cardData) }}</text>
@@ -319,8 +340,9 @@
</template>
<script setup lang="ts">
import { useInviteStore } from '../../stores/invite'
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 {
CardTypeCategory,
@@ -335,7 +357,7 @@ import { get, post } from '../../utils/request'
import { formatPrice, getCardCoverClass } from '../../utils/format'
import { getSystemLayout } from '../../utils/system'
import { useUserStore } from '../../stores/user'
import { requestOrderPaidSubscriptionMessage } from '../../utils/wechat-subscription'
import { requestBookingCreatedSubscriptionMessage } from '../../utils/wechat-subscription'
import CustomNavBar from '../../components/CustomNavBar.vue'
interface MyOrderStatusResponse {
@@ -348,6 +370,31 @@ interface MyOrderStatusResponse {
type PaymentConfirmationResult = 'paid' | 'pending' | 'unavailable' | 'unauthorized'
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 navBarHeight = ref('64px')
const cardId = ref('')
@@ -369,6 +416,22 @@ const paymentRedirecting = ref(false)
const paymentConfirmationSession = ref(0)
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(() => {
if (showAll.value) return '选择会员卡'
return isRenewal.value ? '续卡' : '购买会员卡'
@@ -578,7 +641,7 @@ function cardAccessLabel(cardType: CardType): string {
function getUnitPrice(cardType: CardType): string {
const divisor = cardType.totalTimes === null ? cardType.durationDays : cardType.totalTimes
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 {
@@ -586,6 +649,7 @@ function getUnitPriceLabel(cardType: CardType): string {
}
function getSavingsLabel(cardType: CardType): string {
if (invite.eligible) return '好友价 · 95 折'
if (!cardType.originalPrice || cardType.originalPrice <= cardType.price) return ''
return '省 ¥' + formatPrice(cardType.originalPrice - cardType.price)
}
@@ -698,6 +762,7 @@ async function preparePurchaseConfirmation() {
if (!card.value || isPurchaseBusy.value) return
purchasePreparing.value = true
try {
await invite.refresh()
const refreshed = await refreshMembershipContext()
if (!userStore.loggedIn) {
showLoginPrompt()
@@ -708,6 +773,8 @@ async function preparePurchaseConfirmation() {
return
}
purchaseConfirmVisible.value = true
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '暂时无法核对优惠,请重试'), icon: 'none' })
} finally {
purchasePreparing.value = false
}
@@ -821,13 +888,16 @@ async function doPurchase() {
paymentRedirecting.value = false
paymentConfirmationSession.value++
pendingOrderId.value = ''
uni.showLoading({ title: '创建订单...' })
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', {
cardTypeId: card.value.id,
inviterId: isTrialCard.value && inviterId ? inviterId : undefined,
})
uni.hideLoading()
@@ -845,7 +915,6 @@ async function doPurchase() {
})
pendingOrderId.value = result.order.id
await requestOrderPaidSubscriptionMessage().catch(() => undefined)
await settlePaidOrder(result.order.id)
} catch (err: unknown) {
uni.hideLoading()
@@ -872,6 +941,7 @@ onMounted(() => {
})
onShow(() => {
if (userStore.loggedIn) void invite.refresh().catch(() => {})
if (!paymentConfirming.value && !paymentPending.value) {
void refreshMembershipContext()
}
@@ -883,6 +953,15 @@ onUnmounted(() => {
</script>
<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 {
min-height: 100vh;
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>
<QuickEntry @scroll-to-card-shop="scrollToCardShop" />
<UpcomingBooking />
<ReviewSummaryCard />
<StudioInfo :studio-info="studioStore.studioInfo" />
<FlashSaleSection ref="flashSaleRef" />
<view :id="cardShopAnchorId">
<CardShop ref="cardShopRef" />
</view>
@@ -28,10 +28,10 @@ import { ref, nextTick, onUnmounted } from 'vue'
import { onShow, onResize, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import BrandBanner from '../../components/BrandBanner.vue'
import ReviewSummaryCard from '../../components/ReviewSummaryCard.vue'
import StudioInfo from '../../components/StudioInfo.vue'
import QuickEntry from '../../components/QuickEntry.vue'
import UpcomingBooking from '../../components/UpcomingBooking.vue'
import FlashSaleSection from '../../components/FlashSaleSection.vue'
import CardShop from '../../components/CardShop.vue'
import AboutSection from '../../components/AboutSection.vue'
@@ -62,7 +62,6 @@ onShareTimeline(() => {
// ─── Layout ───────────────────────────────────────────────
const refreshing = ref(false)
const cardShopRef = ref<InstanceType<typeof CardShop> | null>(null)
const flashSaleRef = ref<InstanceType<typeof FlashSaleSection> | null>(null)
const cardShopAnchorId = 'card-shop-anchor'
const scrollTarget = ref('')
const pageHeight = ref(`${uni.getWindowInfo().windowHeight}px`)
@@ -103,10 +102,9 @@ async function refreshData() {
await Promise.allSettled(tasks)
// Also refresh card shop and flash sales
// Also refresh card shop
await Promise.allSettled([
cardShopRef.value?.fetchCardTypes(),
flashSaleRef.value?.fetchFlashSales(),
])
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -57,7 +57,7 @@
<text class="row-end"> {{ endTime(booking) }}</text>
</view>
<text class="row-stamp" :class="stampClass(booking.status)">
{{ bookingStatusLabel(booking.status) }}
{{ booking.review ? '★ ' + booking.review.rating + ' · 已评价' : bookingStatusLabel(booking.status) }}
</text>
</view>
<view class="row-bottom">
@@ -124,7 +124,7 @@
<text class="row-end"> {{ endTime(booking) }}</text>
</view>
<text class="row-stamp" :class="stampClass(booking.status)">
{{ bookingStatusLabel(booking.status) }}
{{ booking.review ? '★ ' + booking.review.rating + ' · 已评价' : bookingStatusLabel(booking.status) }}
</text>
</view>
<text class="row-meta">{{ historyDayLabel(booking.timeSlot.date) }} · {{ cardName(booking) }}</text>
@@ -154,6 +154,7 @@ import {
} from '../../utils/booking-helpers'
import CustomNavBar from '../../components/CustomNavBar.vue'
import LessonSupplementList from '../../components/LessonSupplementList.vue'
import { requestBookingCancelSubscriptionMessage } from '../../utils/wechat-subscription'
type TabKey = 'upcoming' | 'history'
@@ -327,6 +328,12 @@ function goDetail(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 timeLabel = startTime(booking)

View File

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

View File

@@ -1,504 +1,19 @@
<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>
<template><view :style="{ paddingTop: navBarHeight }"><CustomNavBar title="邀请好友" show-back /><InviteCard /></view></template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { onLoad, onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { InviteReferralStatus } from '@mp-pilates/shared'
import { onShareAppMessage, onShow } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import InviteCard from '../../components/InviteCard.vue'
import { useInviteStore } from '../../stores/invite'
import { useUserStore } from '../../stores/user'
import { getSystemLayout } from '../../utils/system'
import { formatDateTime } from '../../utils/format'
import CustomNavBar from '../../components/CustomNavBar.vue'
const inviteStore = useInviteStore()
const userStore = useUserStore()
const navBarHeight = ref('64px')
const summary = computed(() => inviteStore.activity)
const progressWidth = computed(() => {
const current = summary.value?.currentCycleQualifiedCount ?? 0
const total = summary.value?.rewardRuleInvitesRequired ?? 3
return `${Math.min(100, (current / total) * 100)}%`
})
const ruleSteps = [
{ index: '01', title: '分享活动页', desc: '会员用户把活动页转发给微信好友或朋友圈。' },
{ index: '02', title: '好友购买体验课', desc: '新好友通过你的分享进入,并成功购买体验课。' },
{ index: '03', title: '体验课完成核销', desc: '好友到店体验并被老师核销后,这次邀请记为有效。' },
{ index: '04', title: '满 3 人自动加课', desc: '每累计 3 位有效邀请,系统自动给你的会员卡增加 1 节。' },
]
onLoad((query) => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
const inviterId = typeof query?.inviterId === 'string' ? query.inviterId : ''
if (inviterId) {
uni.setStorageSync('invite_inviter_id', inviterId)
const invite = useInviteStore()
const user = useUserStore()
const navBarHeight = `${getSystemLayout().navBarHeight}px`
onShow(() => {
if (user.loggedIn) {
invite.refresh().catch(() => {})
invite.refreshActivity().catch(() => {})
}
})
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'
}
onShareAppMessage(() => ({ title: '送你 95 折购卡礼,一起练普拉提', path: invite.code ? `/pages/card/detail?showAll=1&inviteCode=${invite.code}` : '/pages/home/index' }))
</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 }">
<CustomNavBar title="我的会员卡" show-back />
<scroll-view class="scroll" scroll-y refresher-enabled :refresher-triggered="refreshing" @refresherrefresh="onRefresh">
<view v-if="loading && !refreshing && !allMemberships.length" class="loading-wrap">
<view v-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>
@@ -20,40 +32,15 @@
<text class="group-title">正在使用</text>
<text class="group-count">{{ activeMemberships.length }} 张有效卡</text>
</view>
<view v-for="m in activeMemberships" :key="m.id" class="mc" :class="cardBgClass(m.cardType.type)">
<view class="mc-top">
<view class="mc-name-area">
<text class="mc-name">{{ m.cardType.name }}</text>
<text class="mc-type-text">{{ getCardTypeLabel(m.cardType.type) }}</text>
</view>
<text class="mc-status 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 v-for="m in activeMemberships" :key="m.id" class="owned-card-wrap">
<OwnedMembershipCard :membership="m" :now="membershipNow">
<view class="mc-actions">
<button v-if="canRenewMembership(m)" class="mc-renew" @tap="goRenew(m)">续卡</button>
<button class="mc-book" @tap="goBooking">预约课程</button>
</view>
</OwnedMembershipCard>
</view>
<text class="usage-note">已用次数包含预约扣次不等同于已完成上课进度条表示已用次数占比</text>
</view>
<view v-if="inactiveMemberships.length" class="group-section">
@@ -81,7 +68,7 @@
</view>
<view class="scroll-bottom-spacer" />
</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>
</view>
</view>
@@ -94,15 +81,17 @@ import type { MembershipWithCardType } from '@mp-pilates/shared'
import { MembershipStatus, CardTypeCategory } from '@mp-pilates/shared'
import { useUserStore } from '../../stores/user'
import { getSystemLayout } from '../../utils/system'
import { getCardTypeLabel, getMembershipProgressWidth, getMembershipUsedTimes, getMembershipTotalTimes } from '../../utils/format'
import { getCardTypeLabel } from '../../utils/format'
import CustomNavBar from '../../components/CustomNavBar.vue'
import OwnedMembershipCard from '../../components/OwnedMembershipCard.vue'
const userStore = useUserStore()
const navBarHeight = `${getSystemLayout().navBarHeight}px`
const pageHeight = ref(`${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 allMemberships = computed(() => userStore.memberships as MembershipWithCardType[])
@@ -129,36 +118,23 @@ function inactiveStatusClass(status: MembershipStatus): string {
return 'mc-status--expired'
}
function cardBgClass(type: CardTypeCategory): string {
if (type === CardTypeCategory.TRIAL) return 'mc--trial'
if (type === CardTypeCategory.DURATION) return 'mc--duration'
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))
}
const finiteBalance = computed(() => activeMemberships.value.reduce((sum, m) => sum + Math.max(0, m.remainingTimes ?? 0), 0))
const unlimitedCount = computed(() => activeMemberships.value.filter(m => m.remainingTimes === null).length)
const remainingLabel = computed(() => !userStore.membershipsLoaded || userStore.membershipsError ? '—' : finiteBalance.value > 0 ? finiteBalance.value : unlimitedCount.value ? '不限次' : 0)
async function loadMemberships() {
loading.value = true
try {
await userStore.fetchMemberships()
} catch {
uni.showToast({ title: '加载失败,请下拉刷新', icon: 'none' })
} finally {
loading.value = false
}
membershipNow.value = Date.now()
if (!userStore.loggedIn) return
await Promise.all([userStore.fetchMemberships(), userStore.fetchStats()])
}
async function onRefresh() {
if (refreshing.value) return
refreshing.value = true
try {
await userStore.fetchMemberships()
} finally {
refreshing.value = false
}
try { await loadMemberships() }
finally { refreshing.value = false }
}
function goProfile() { uni.switchTab({ url: '/pages/profile/index' }) }
function goBooking() {
uni.switchTab({ url: '/pages/booking/index' })
@@ -170,7 +146,7 @@ function goStore() {
}
function canRenewMembership(m: MembershipWithCardType): boolean {
return m.cardType.type !== CardTypeCategory.TRIAL
return m.cardType.isActive && m.cardType.type !== CardTypeCategory.TRIAL
}
function goRenew(m: MembershipWithCardType) {
@@ -196,28 +172,12 @@ onShow(loadMemberships)
.group-title { font-size: 28rpx; font-weight: 500; }
.group-count { font-size: 22rpx; color: #8b817b; }
.mc { --balance-bg: #f3ebe3; --balance-ink: #8b6c5b; padding: 28rpx; margin-bottom: 20rpx; border: 1rpx solid #eee8e3; border-radius: 28rpx; background: #fff; }
.mc--duration { --balance-bg: #edf2e9; --balance-ink: #617d63; }
.mc--trial { --balance-bg: #f5e9e5; --balance-ink: #9b7768; }
.mc-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 20rpx; }
.mc-name-area { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
.mc-name { font-size: 30rpx; font-weight: 500; line-height: 1.5; overflow-wrap: anywhere; }
.mc-type-text { font-size: 22rpx; color: #8b817b; }
.mc-status { flex-shrink: 0; padding: 6rpx 16rpx; border-radius: 999rpx; background: #ede9e4; color: #8b817b; font-size: 21rpx; line-height: 1.4; }
.mc-status--active { background: #edf2ec; color: #617d73; }
.mc-balance { margin-top: 24rpx; padding: 22rpx 24rpx; background: var(--balance-bg); border-radius: 20rpx; }
.mc-number-row { display: flex; align-items: baseline; gap: 10rpx; }
.mc-big-num { font-size: 56rpx; font-weight: 400; line-height: 1.1; color: var(--balance-ink); font-variant-numeric: tabular-nums; }
.mc-big-unit { font-size: 23rpx; color: var(--balance-ink); }
.mc-duration-note { display: block; font-size: 21rpx; color: #7b8775; margin-top: 12rpx; }
.mc-progress { margin-top: 18rpx; }
.mc-progress-track { height: 6rpx; border-radius: 6rpx; overflow: hidden; background: rgba(255,255,255,0.8); }
.mc-progress-fill { height: 100%; border-radius: 6rpx; background: #b9a38f; }
.mc-progress-label { display: block; margin-top: 10rpx; font-size: 21rpx; color: #8b7b70; }
.mc-bottom { display: flex; justify-content: space-between; gap: 20rpx; margin-top: 22rpx; }
.mc-date-item { min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
.mc-date-item--end { text-align: right; }
.mc-date-label { font-size: 21rpx; color: #8b817b; line-height: 1.6; }
.mc-date-value { font-size: 24rpx; color: #6f655e; font-variant-numeric: tabular-nums; }
.mc-actions { display: flex; gap: 16rpx; margin-top: 26rpx; }
.mc-renew, .mc-book { flex: 1; margin: 0; padding: 0 24rpx; height: 72rpx; line-height: 72rpx; border: none; border-radius: 999rpx; font-size: 25rpx; font-weight: 400; &::after { border: none; } }
.mc-renew { background: #f3eee8; color: #8b7160; }
@@ -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-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; }
.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>

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

View File

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

View File

@@ -9,6 +9,7 @@ import type {
TeachingScheduleSlot,
} from '@mp-pilates/shared'
import { get, post, put } from '../utils/request'
import { useBodyPortraitStore } from './body-portrait'
/** Server paginated responses use `data` field, not `items` from the shared type */
interface ServerPaginatedResult<T> {
@@ -40,7 +41,12 @@ export const useBookingStore = defineStore('booking', () => {
}
async function createBooking(dto: CreateBookingDto) {
const result = await post<BookingWithDetails>('/booking', dto as unknown as Record<string, unknown>)
const portraitStore = useBodyPortraitStore()
const originAssessmentId = dto.originAssessmentId || (portraitStore.claimed ? portraitStore.assessmentId : undefined)
const result = await post<BookingWithDetails>('/booking', {
...dto,
...(originAssessmentId ? { originAssessmentId } : {}),
} as unknown as Record<string, unknown>)
return result
}

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

View File

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

View File

@@ -1,3 +1,5 @@
import { useInviteStore } from './invite'
import { useBodyPortraitStore } from './body-portrait'
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type {
@@ -25,6 +27,13 @@ export const useUserStore = defineStore('user', () => {
const user = ref<UserProfileResponse | null>(null)
const stats = ref<UserStatsResponse | null>(null)
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 || '')
// Getters
@@ -46,6 +55,7 @@ export const useUserStore = defineStore('user', () => {
const result = await wxLogin()
token.value = result.token
user.value = result.user
await useInviteStore().refresh().catch(() => {})
syncSubscriptionTemplates(result.user)
return { user: result.user, isNewUser: result.isNewUser }
} catch (err) {
@@ -72,6 +82,7 @@ export const useUserStore = defineStore('user', () => {
if (!isLoggedIn()) return
try {
user.value = await get<UserProfileResponse>('/user/profile')
await useInviteStore().refresh().catch(() => {})
syncSubscriptionTemplates(user.value)
return user.value
} catch (err) {
@@ -79,23 +90,42 @@ export const useUserStore = defineStore('user', () => {
}
}
async function fetchStats() {
if (!isLoggedIn()) return
async function fetchStats(): Promise<boolean> {
if (!isLoggedIn()) return false
const id = ++statsRequestId
const session = token.value
statsLoading.value = true
statsError.value = false
try {
stats.value = await get<UserStatsResponse>('/user/stats')
} catch (err) {
console.error('Fetch stats failed:', err)
const result = await get<UserStatsResponse>('/user/stats')
if (id !== statsRequestId || session !== token.value) return false
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> {
if (!isLoggedIn()) return false
const id = ++membershipRequestId
const session = token.value
membershipsLoading.value = true
membershipsError.value = false
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
} catch (err) {
console.error('Fetch memberships failed:', err)
} catch {
if (id === membershipRequestId && session === token.value) membershipsError.value = true
return false
} finally {
if (id === membershipRequestId && session === token.value) membershipsLoading.value = false
}
}
@@ -119,11 +149,20 @@ export const useUserStore = defineStore('user', () => {
}
function clearSession() {
membershipRequestId++
statsRequestId++
membershipsLoading.value = false
membershipsLoaded.value = false
membershipsError.value = false
statsLoading.value = false
statsError.value = false
token.value = ''
useInviteStore().reset()
user.value = null
stats.value = null
memberships.value = []
resetSubscriptionMessageTemplateCache()
useBodyPortraitStore().keepAnonymousOnLogout()
}
function logout() {
@@ -134,6 +173,11 @@ export const useUserStore = defineStore('user', () => {
setUnauthorizedHandler(clearSession)
return {
membershipsLoading,
membershipsLoaded,
membershipsError,
statsLoading,
statsError,
user,
stats,
memberships,

View File

@@ -1,5 +1,4 @@
import { CardTypeCategory } from '@mp-pilates/shared'
import { FlashSalePhase } from '@mp-pilates/shared'
/** Minimal membership shape needed by progress/usage helpers. */
interface MembershipLike {
@@ -13,6 +12,13 @@ export function formatPrice(cents: number): string {
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 */
export function formatDate(date: Date | string): string {
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 {
if (totalStock === 0) return 0

View File

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

View File

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

View File

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

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

@@ -0,0 +1,196 @@
-- AlterTable
ALTER TABLE `bookings` ADD COLUMN `origin_assessment_id` VARCHAR(191) NULL;
ALTER TABLE `progress_photos` ADD COLUMN `angle` VARCHAR(16) NULL,
ADD COLUMN `session_id` VARCHAR(191) NULL;
-- CreateTable
CREATE TABLE `body_portrait_visits` (
`id` VARCHAR(191) NOT NULL,
`token_hash` VARCHAR(64) NOT NULL,
`source` VARCHAR(32) NOT NULL DEFAULT 'organic',
`campaign_id` VARCHAR(40) NULL,
`referral_code` VARCHAR(12) NULL,
`referrer_user_id` VARCHAR(191) NULL,
`landing_path` VARCHAR(120) NOT NULL DEFAULT '',
`user_id` VARCHAR(191) NULL,
`first_seen_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`last_seen_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `body_portrait_visits_token_hash_key`(`token_hash`),
INDEX `body_portrait_visits_source_created_at_idx`(`source`, `created_at`),
INDEX `body_portrait_visits_user_id_idx`(`user_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `body_portrait_assessments` (
`id` VARCHAR(191) NOT NULL,
`visit_id` VARCHAR(191) NOT NULL,
`user_id` VARCHAR(191) NULL,
`token_hash` VARCHAR(64) NOT NULL,
`questionnaire_version` VARCHAR(32) NOT NULL,
`rules_version` VARCHAR(32) NOT NULL,
`status` VARCHAR(16) NOT NULL DEFAULT 'DRAFT',
`answers` JSON NOT NULL,
`safety_flagged` BOOLEAN NOT NULL DEFAULT false,
`report` JSON NULL,
`completed_at` DATETIME(3) NULL,
`claimed_at` DATETIME(3) NULL,
`expires_at` DATETIME(3) NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL,
UNIQUE INDEX `body_portrait_assessments_token_hash_key`(`token_hash`),
INDEX `body_portrait_assessments_visit_id_idx`(`visit_id`),
INDEX `body_portrait_assessments_user_id_created_at_idx`(`user_id`, `created_at`),
INDEX `body_portrait_assessments_status_expires_at_idx`(`status`, `expires_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `growth_leads` (
`id` VARCHAR(191) NOT NULL,
`user_id` VARCHAR(191) NOT NULL,
`latest_assessment_id` VARCHAR(191) NULL,
`stage` VARCHAR(32) NOT NULL DEFAULT 'CLAIMED',
`source` VARCHAR(32) NULL,
`campaign_id` VARCHAR(40) NULL,
`last_active_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`coach_note` VARCHAR(500) NOT NULL DEFAULT '',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL,
UNIQUE INDEX `growth_leads_user_id_key`(`user_id`),
INDEX `growth_leads_stage_last_active_at_idx`(`stage`, `last_active_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `growth_events` (
`id` VARCHAR(191) NOT NULL,
`name` VARCHAR(40) NOT NULL,
`idempotency_key` VARCHAR(120) NOT NULL,
`visit_id` VARCHAR(191) NULL,
`assessment_id` VARCHAR(191) NULL,
`user_id` VARCHAR(191) NULL,
`properties` JSON NOT NULL,
`occurred_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `growth_events_idempotency_key_key`(`idempotency_key`),
INDEX `growth_events_name_occurred_at_idx`(`name`, `occurred_at`),
INDEX `growth_events_user_id_occurred_at_idx`(`user_id`, `occurred_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `professional_assessment_sessions` (
`id` VARCHAR(191) NOT NULL,
`user_id` VARCHAR(191) NOT NULL,
`kind` VARCHAR(16) NOT NULL,
`protocol_version` VARCHAR(32) NOT NULL DEFAULT 'offline-v1',
`recorded_at` DATE NOT NULL,
`booking_id` VARCHAR(191) NULL,
`origin_assessment_id` VARCHAR(191) NULL,
`observations` JSON NOT NULL,
`subjective_tension` DOUBLE NULL,
`coach_summary` VARCHAR(1000) NOT NULL DEFAULT '',
`training_focus` VARCHAR(500) NOT NULL DEFAULT '',
`phase_goal` VARCHAR(500) NOT NULL DEFAULT '',
`photo_angle` VARCHAR(16) NULL,
`operator_id` VARCHAR(191) NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `professional_assessment_sessions_user_id_recorded_at_idx`(`user_id`, `recorded_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `training_plans` (
`id` VARCHAR(191) NOT NULL,
`user_id` VARCHAR(191) NOT NULL,
`session_id` VARCHAR(191) NULL,
`title` VARCHAR(80) NOT NULL,
`status` VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
`weeks` INTEGER NOT NULL DEFAULT 12,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL,
INDEX `training_plans_user_id_created_at_idx`(`user_id`, `created_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `training_plan_phases` (
`id` VARCHAR(191) NOT NULL,
`plan_id` VARCHAR(191) NOT NULL,
`name` VARCHAR(40) NOT NULL,
`lesson_start` INTEGER NOT NULL,
`lesson_end` INTEGER NOT NULL,
`focus` JSON NOT NULL,
`summary` VARCHAR(300) NOT NULL DEFAULT '',
`sort_order` INTEGER NOT NULL DEFAULT 0,
INDEX `training_plan_phases_plan_id_idx`(`plan_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `reassessment_todos` (
`id` VARCHAR(191) NOT NULL,
`user_id` VARCHAR(191) NOT NULL,
`plan_id` VARCHAR(191) NOT NULL,
`lesson_checkpoint` INTEGER NOT NULL,
`due_at` DATETIME(3) NOT NULL,
`completed_at` DATETIME(3) NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `reassessment_todos_plan_id_lesson_checkpoint_key`(`plan_id`, `lesson_checkpoint`),
INDEX `reassessment_todos_user_id_completed_at_idx`(`user_id`, `completed_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `growth_share_cards` (
`id` VARCHAR(191) NOT NULL,
`user_id` VARCHAR(191) NOT NULL,
`plan_id` VARCHAR(191) NULL,
`share_code` VARCHAR(12) NOT NULL,
`title` VARCHAR(80) NOT NULL,
`caption` VARCHAR(200) NOT NULL,
`completed_count` INTEGER NOT NULL,
`weeks` INTEGER NOT NULL DEFAULT 12,
`rows` JSON NOT NULL,
`include_photos` BOOLEAN NOT NULL DEFAULT false,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `growth_share_cards_share_code_key`(`share_code`),
INDEX `growth_share_cards_user_id_created_at_idx`(`user_id`, `created_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE INDEX `bookings_origin_assessment_id_idx` ON `bookings`(`origin_assessment_id`);
CREATE INDEX `progress_photos_session_id_idx` ON `progress_photos`(`session_id`);
-- AddForeignKey
ALTER TABLE `body_portrait_visits` ADD CONSTRAINT `body_portrait_visits_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE `body_portrait_visits` ADD CONSTRAINT `body_portrait_visits_referrer_user_id_fkey` FOREIGN KEY (`referrer_user_id`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE `body_portrait_assessments` ADD CONSTRAINT `body_portrait_assessments_visit_id_fkey` FOREIGN KEY (`visit_id`) REFERENCES `body_portrait_visits`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE `body_portrait_assessments` ADD CONSTRAINT `body_portrait_assessments_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE `growth_leads` ADD CONSTRAINT `growth_leads_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE `growth_leads` ADD CONSTRAINT `growth_leads_latest_assessment_id_fkey` FOREIGN KEY (`latest_assessment_id`) REFERENCES `body_portrait_assessments`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE `growth_events` ADD CONSTRAINT `growth_events_visit_id_fkey` FOREIGN KEY (`visit_id`) REFERENCES `body_portrait_visits`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE `growth_events` ADD CONSTRAINT `growth_events_assessment_id_fkey` FOREIGN KEY (`assessment_id`) REFERENCES `body_portrait_assessments`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE `growth_events` ADD CONSTRAINT `growth_events_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE `bookings` ADD CONSTRAINT `bookings_origin_assessment_id_fkey` FOREIGN KEY (`origin_assessment_id`) REFERENCES `body_portrait_assessments`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE `professional_assessment_sessions` ADD CONSTRAINT `professional_assessment_sessions_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE `professional_assessment_sessions` ADD CONSTRAINT `professional_assessment_sessions_booking_id_fkey` FOREIGN KEY (`booking_id`) REFERENCES `bookings`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE `professional_assessment_sessions` ADD CONSTRAINT `professional_assessment_sessions_origin_assessment_id_fkey` FOREIGN KEY (`origin_assessment_id`) REFERENCES `body_portrait_assessments`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE `progress_photos` ADD CONSTRAINT `progress_photos_session_id_fkey` FOREIGN KEY (`session_id`) REFERENCES `professional_assessment_sessions`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE `training_plans` ADD CONSTRAINT `training_plans_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE `training_plans` ADD CONSTRAINT `training_plans_session_id_fkey` FOREIGN KEY (`session_id`) REFERENCES `professional_assessment_sessions`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE `training_plan_phases` ADD CONSTRAINT `training_plan_phases_plan_id_fkey` FOREIGN KEY (`plan_id`) REFERENCES `training_plans`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE `reassessment_todos` ADD CONSTRAINT `reassessment_todos_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE `reassessment_todos` ADD CONSTRAINT `reassessment_todos_plan_id_fkey` FOREIGN KEY (`plan_id`) REFERENCES `training_plans`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE `growth_share_cards` ADD CONSTRAINT `growth_share_cards_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE `growth_share_cards` ADD CONSTRAINT `growth_share_cards_plan_id_fkey` FOREIGN KEY (`plan_id`) REFERENCES `training_plans`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -51,18 +51,6 @@ enum OrderStatus {
REFUNDED
}
enum FlashSaleStatus {
DRAFT
ACTIVE
ENDED
}
enum FlashSaleOrderStatus {
RESERVED
PAID
EXPIRED
}
enum InviteReferralStatus {
REGISTERED
TRIAL_PURCHASED
@@ -73,6 +61,7 @@ enum InviteReferralStatus {
model User {
id String @id @default(uuid())
inviteCode String? @unique @map("invite_code") @db.VarChar(6)
openid String @unique
unionid String?
phone String?
@@ -84,15 +73,26 @@ model User {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
bodyMetrics BodyMetric[]
memberNotes MemberNote[]
progressPhotos ProgressPhoto[]
lessonSupplements LessonSupplement[]
memberships Membership[]
bookings Booking[]
orders Order[]
flashSaleOrders FlashSaleOrder[]
subscriptionMessageConsents SubscriptionMessageConsent[]
sentInviteReferrals InviteReferral[] @relation("InviteReferralInviter")
receivedInviteReferral InviteReferral[] @relation("InviteReferralInvitee")
inviteRewardGrants InviteRewardGrant[] @relation("InviteRewardGrantInviter")
bodyPortraitVisits BodyPortraitVisit[] @relation("PortraitVisitUser")
referredPortraitVisits BodyPortraitVisit[] @relation("PortraitReferrer")
bodyPortraitAssessments BodyPortraitAssessment[]
growthLead GrowthLead?
growthEvents GrowthEvent[]
professionalAssessments ProfessionalAssessmentSession[]
trainingPlans TrainingPlan[]
reassessmentTodos ReassessmentTodo[]
growthShareCards GrowthShareCard[]
@@map("users")
}
@@ -139,7 +139,6 @@ model CardType {
memberships Membership[]
orders Order[]
flashSales FlashSale[]
@@map("card_types")
}
@@ -217,19 +216,31 @@ model Booking {
confirmedAt DateTime? @map("confirmed_at")
completedAt DateTime? @map("completed_at")
operatorId String? @map("operator_id")
originAssessmentId String? @map("origin_assessment_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id])
timeSlot TimeSlot @relation(fields: [timeSlotId], references: [id])
membership Membership @relation(fields: [membershipId], references: [id])
originAssessment BodyPortraitAssessment? @relation(fields: [originAssessmentId], references: [id])
qualifiedInviteReferrals InviteReferral[]
professionalAssessments ProfessionalAssessmentSession[]
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[]
@@unique([userId, timeSlotId])
@@index([userId])
@@index([status])
@@index([originAssessmentId])
@@map("bookings")
}
@@ -254,18 +265,18 @@ model Order {
cardTypeId String @map("card_type_id")
membershipId String? @map("membership_id")
orderNo String @unique @map("order_no")
inviteInviterId String? @map("invite_inviter_id")
purchasedCategory String? @map("purchased_category")
amount Decimal @db.Decimal(10, 0)
status OrderStatus @default(PENDING)
wxTransactionId String? @map("wx_transaction_id")
paidAt DateTime? @map("paid_at")
flashSaleId String? @map("flash_sale_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id])
cardType CardType @relation(fields: [cardTypeId], references: [id])
membership Membership? @relation(fields: [membershipId], references: [id])
flashSaleOrder FlashSaleOrder?
inviteReferrals InviteReferral[]
@@index([userId])
@@ -331,51 +342,6 @@ model StudioConfig {
@@map("studio_config")
}
model FlashSale {
id String @id @default(uuid())
cardTypeId String @map("card_type_id")
title String
originalPrice Decimal @map("original_price") @db.Decimal(10, 0)
flashPrice Decimal @map("flash_price") @db.Decimal(10, 0)
totalStock Int @map("total_stock")
soldCount Int @default(0) @map("sold_count")
startTime DateTime @map("start_time")
endTime DateTime @map("end_time")
status FlashSaleStatus @default(DRAFT)
description String? @db.Text
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
cardType CardType @relation(fields: [cardTypeId], references: [id])
orders FlashSaleOrder[]
@@index([status, startTime, endTime])
@@map("flash_sales")
}
model FlashSaleOrder {
id String @id @default(uuid())
flashSaleId String @map("flash_sale_id")
userId String @map("user_id")
orderId String? @unique @map("order_id")
status FlashSaleOrderStatus @default(RESERVED)
reservedAt DateTime @default(now()) @map("reserved_at")
paidAt DateTime? @map("paid_at")
expiredAt DateTime? @map("expired_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
flashSale FlashSale @relation(fields: [flashSaleId], references: [id])
user User @relation(fields: [userId], references: [id])
order Order? @relation(fields: [orderId], references: [id])
@@unique([flashSaleId, userId])
@@index([userId])
@@index([status])
@@map("flash_sale_orders")
}
// Historical totals without fabricated scheduled dates.
model LessonSupplement {
id String @id @default(uuid())
@@ -399,3 +365,258 @@ model LessonSupplement {
@@index([userId, revokedAt, createdAt])
@@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")
angle String? @db.VarChar(16)
sessionId String? @map("session_id")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id])
session ProfessionalAssessmentSession? @relation(fields: [sessionId], references: [id])
@@index([userId, recordedAt])
@@index([sessionId])
@@map("progress_photos")
}
model BodyPortraitVisit {
id String @id @default(uuid())
tokenHash String @unique @map("token_hash") @db.VarChar(64)
source String @default("organic") @db.VarChar(32)
campaignId String? @map("campaign_id") @db.VarChar(40)
referralCode String? @map("referral_code") @db.VarChar(12)
referrerUserId String? @map("referrer_user_id")
landingPath String @default("") @map("landing_path") @db.VarChar(120)
userId String? @map("user_id")
firstSeenAt DateTime @default(now()) @map("first_seen_at")
lastSeenAt DateTime @default(now()) @map("last_seen_at")
createdAt DateTime @default(now()) @map("created_at")
user User? @relation("PortraitVisitUser", fields: [userId], references: [id])
referrer User? @relation("PortraitReferrer", fields: [referrerUserId], references: [id])
assessments BodyPortraitAssessment[]
events GrowthEvent[]
@@index([source, createdAt])
@@index([userId])
@@map("body_portrait_visits")
}
model BodyPortraitAssessment {
id String @id @default(uuid())
visitId String @map("visit_id")
userId String? @map("user_id")
tokenHash String @unique @map("token_hash") @db.VarChar(64)
questionnaireVersion String @map("questionnaire_version") @db.VarChar(32)
rulesVersion String @map("rules_version") @db.VarChar(32)
status String @default("DRAFT") @db.VarChar(16)
answers Json @default("{}")
safetyFlagged Boolean @default(false) @map("safety_flagged")
report Json?
completedAt DateTime? @map("completed_at")
claimedAt DateTime? @map("claimed_at")
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
visit BodyPortraitVisit @relation(fields: [visitId], references: [id])
user User? @relation(fields: [userId], references: [id])
bookings Booking[]
growthLeads GrowthLead[]
events GrowthEvent[]
professionalAssessments ProfessionalAssessmentSession[]
@@index([visitId])
@@index([userId, createdAt])
@@index([status, expiresAt])
@@map("body_portrait_assessments")
}
model GrowthLead {
id String @id @default(uuid())
userId String @unique @map("user_id")
latestAssessmentId String? @map("latest_assessment_id")
stage String @default("CLAIMED") @db.VarChar(32)
source String? @db.VarChar(32)
campaignId String? @map("campaign_id") @db.VarChar(40)
lastActiveAt DateTime @default(now()) @map("last_active_at")
coachNote String @default("") @map("coach_note") @db.VarChar(500)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id])
latestAssessment BodyPortraitAssessment? @relation(fields: [latestAssessmentId], references: [id])
@@index([stage, lastActiveAt])
@@map("growth_leads")
}
model GrowthEvent {
id String @id @default(uuid())
name String @db.VarChar(40)
idempotencyKey String @unique @map("idempotency_key") @db.VarChar(120)
visitId String? @map("visit_id")
assessmentId String? @map("assessment_id")
userId String? @map("user_id")
properties Json @default("{}")
occurredAt DateTime @default(now()) @map("occurred_at")
visit BodyPortraitVisit? @relation(fields: [visitId], references: [id])
assessment BodyPortraitAssessment? @relation(fields: [assessmentId], references: [id])
user User? @relation(fields: [userId], references: [id])
@@index([name, occurredAt])
@@index([userId, occurredAt])
@@map("growth_events")
}
model ProfessionalAssessmentSession {
id String @id @default(uuid())
userId String @map("user_id")
kind String @db.VarChar(16)
protocolVersion String @default("offline-v1") @map("protocol_version") @db.VarChar(32)
recordedAt DateTime @db.Date @map("recorded_at")
bookingId String? @map("booking_id")
originAssessmentId String? @map("origin_assessment_id")
observations Json
subjectiveTension Float? @map("subjective_tension")
coachSummary String @default("") @map("coach_summary") @db.VarChar(1000)
trainingFocus String @default("") @map("training_focus") @db.VarChar(500)
phaseGoal String @default("") @map("phase_goal") @db.VarChar(500)
photoAngle String? @map("photo_angle") @db.VarChar(16)
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])
originAssessment BodyPortraitAssessment? @relation(fields: [originAssessmentId], references: [id])
photos ProgressPhoto[]
trainingPlans TrainingPlan[]
@@index([userId, recordedAt])
@@map("professional_assessment_sessions")
}
model TrainingPlan {
id String @id @default(uuid())
userId String @map("user_id")
sessionId String? @map("session_id")
title String @db.VarChar(80)
status String @default("ACTIVE") @db.VarChar(16)
weeks Int @default(12)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id])
session ProfessionalAssessmentSession? @relation(fields: [sessionId], references: [id])
phases TrainingPlanPhase[]
todos ReassessmentTodo[]
shares GrowthShareCard[]
@@index([userId, createdAt])
@@map("training_plans")
}
model TrainingPlanPhase {
id String @id @default(uuid())
planId String @map("plan_id")
name String @db.VarChar(40)
lessonStart Int @map("lesson_start")
lessonEnd Int @map("lesson_end")
focus Json
summary String @default("") @db.VarChar(300)
sortOrder Int @default(0) @map("sort_order")
plan TrainingPlan @relation(fields: [planId], references: [id], onDelete: Cascade)
@@index([planId])
@@map("training_plan_phases")
}
model ReassessmentTodo {
id String @id @default(uuid())
userId String @map("user_id")
planId String @map("plan_id")
lessonCheckpoint Int @map("lesson_checkpoint")
dueAt DateTime @map("due_at")
completedAt DateTime? @map("completed_at")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id])
plan TrainingPlan @relation(fields: [planId], references: [id], onDelete: Cascade)
@@unique([planId, lessonCheckpoint])
@@index([userId, completedAt])
@@map("reassessment_todos")
}
model GrowthShareCard {
id String @id @default(uuid())
userId String @map("user_id")
planId String? @map("plan_id")
shareCode String @unique @map("share_code") @db.VarChar(12)
title String @db.VarChar(80)
caption String @db.VarChar(200)
completedCount Int @map("completed_count")
weeks Int @default(12)
rows Json
includePhotos Boolean @default(false) @map("include_photos")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id])
plan TrainingPlan? @relation(fields: [planId], references: [id])
@@index([userId, createdAt])
@@map("growth_share_cards")
}

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 { Roles } from '../auth/roles.decorator'
import { RolesGuard } from '../auth/roles.guard'
import { UserRole } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
interface AdminStats {
todayBookings: number
totalOrders: number
totalBookings: number
}
@Controller('admin')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
export class AdminController {
constructor(private readonly prisma: PrismaService) {}
constructor(private readonly analytics: TeachingAnalyticsService) {}
@Get('stats')
async getStats(): Promise<AdminStats> {
const today = new Date()
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 }
@Get('teaching-analytics')
getTeachingAnalytics(@Query('month') month: string) {
return this.analytics.getMonthly(month)
}
}

View File

@@ -1,7 +1,9 @@
import { TeachingAnalyticsService } from './teaching-analytics.service'
import { Module } from '@nestjs/common'
import { AdminController } from './admin.controller'
@Module({
controllers: [AdminController],
providers: [TeachingAnalyticsService],
})
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,8 +11,8 @@ import { BookingModule } from './booking/booking.module'
import { SchedulerModule } from './scheduler/scheduler.module'
import { PaymentModule } from './payment/payment.module'
import { AdminModule } from './admin/admin.module'
import { FlashSaleModule } from './flash-sale/flash-sale.module'
import { InviteModule } from './invite/invite.module'
import { BodyPortraitModule } from './body-portrait/body-portrait.module'
@Module({
imports: [
@@ -30,8 +30,8 @@ import { InviteModule } from './invite/invite.module'
SchedulerModule,
PaymentModule,
AdminModule,
FlashSaleModule,
InviteModule,
BodyPortraitModule,
],
controllers: [AppController],
})

View File

@@ -84,7 +84,7 @@ describe('AuthService', () => {
jest.clearAllMocks()
mockJwtService.sign.mockReturnValue(JWT_TOKEN)
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 ──────────────────────────────────────────────────────────────────

View File

@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common'
import { Module, forwardRef } from '@nestjs/common'
import { PassportModule } from '@nestjs/passport'
import { JwtModule } from '@nestjs/jwt'
import { ConfigModule, ConfigService } from '@nestjs/config'
@@ -10,6 +10,7 @@ import { JwtStrategy } from './jwt.strategy'
import { JwtAuthGuard } from './jwt-auth.guard'
import { RolesGuard } from './roles.guard'
import { InviteModule } from '../invite/invite.module'
import { BodyPortraitModule } from '../body-portrait/body-portrait.module'
@Module({
imports: [
@@ -17,6 +18,7 @@ import { InviteModule } from '../invite/invite.module'
InviteModule,
ConfigModule,
MembershipModule,
forwardRef(() => BodyPortraitModule),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],

View File

@@ -1,4 +1,4 @@
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'
import { Inject, Injectable, Optional, UnauthorizedException, forwardRef } from '@nestjs/common'
import { JwtService } from '@nestjs/jwt'
import { User } from '@prisma/client'
import {
@@ -13,6 +13,7 @@ import { ConfigService } from '@nestjs/config'
import { PrismaService } from '../prisma/prisma.service'
import { WechatService } from './wechat.service'
import { InviteService } from '../invite/invite.service'
import { BodyPortraitLifecycleService } from '../body-portrait/body-portrait-lifecycle.service'
export interface LoginResult {
token: string
@@ -67,10 +68,14 @@ export class AuthService {
private readonly inviteService: InviteService,
private readonly configService: ConfigService,
@Inject(RANDOM_FN_TOKEN) private readonly randomFn: () => number = Math.random,
@Optional()
@Inject(forwardRef(() => BodyPortraitLifecycleService))
private readonly portraitLifecycle?: BodyPortraitLifecycleService,
) {}
private buildSubscriptionTemplateConfig(): SubscriptionMessageTemplateConfig {
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', ''),
scene: SubscriptionMessageScene.BOOKING_CREATED,
@@ -178,9 +183,11 @@ export class AuthService {
iv,
)
return this.prisma.user.update({
const updated = await this.prisma.user.update({
where: { id: userId },
data: { phone: phoneInfo.phoneNumber },
})
await this.portraitLifecycle?.onPhoneBound(userId)
return updated
}
}

View File

@@ -0,0 +1,121 @@
import { BadRequestException } from '@nestjs/common'
import { ProfessionalAssessmentKind } from '@mp-pilates/shared'
import { BodyPortraitOfflineService } from '../body-portrait-offline.service'
import { PrismaService } from '../../prisma/prisma.service'
describe('BodyPortraitOfflineService', () => {
const prisma: any = {
user: { findUnique: jest.fn() },
booking: { findFirst: jest.fn(), count: jest.fn() },
bodyPortraitAssessment: { findFirst: jest.fn() },
professionalAssessmentSession: { create: jest.fn(), findMany: jest.fn(), findFirst: jest.fn() },
reassessmentTodo: { findFirst: jest.fn(), update: jest.fn(), createMany: jest.fn(), findMany: jest.fn() },
trainingPlan: { create: jest.fn(), findMany: jest.fn(), findFirst: jest.fn() },
growthShareCard: { create: jest.fn(), findUnique: jest.fn() },
}
let service: BodyPortraitOfflineService
beforeEach(() => {
jest.resetAllMocks()
prisma.user.findUnique.mockResolvedValue({ id: 'u1' })
service = new BodyPortraitOfflineService(prisma as unknown as PrismaService)
})
it('rejects assessment recorded in the future based on China timezone', async () => {
const futureDate = new Date(Date.now() + 86400000 * 2).toISOString().slice(0, 10)
await expect(
service.createSession('u1', 'op1', {
kind: ProfessionalAssessmentKind.INITIAL,
recordedAt: futureDate,
observations: {
headPosition: 3, shoulderPosition: 3, thoracicExtension: 3, shoulderFlexion: 3,
breathing: 3, pelvis: 3, coreControl: 3, hipMobility: 3, singleLeg: 3,
},
coachSummary: 'test',
trainingFocus: 'test',
phaseGoal: 'test',
}),
).rejects.toThrow(BadRequestException)
})
it('completes only the earliest pending reassessment todo on follow-up session', async () => {
const today = new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10)
prisma.professionalAssessmentSession.create.mockResolvedValue({
id: 'sess-2',
userId: 'u1',
kind: ProfessionalAssessmentKind.FOLLOW_UP,
protocolVersion: 'offline-v1',
recordedAt: new Date(`${today}T00:00:00.000Z`),
bookingId: null,
originAssessmentId: null,
observations: {
headPosition: 3, shoulderPosition: 3, thoracicExtension: 3, shoulderFlexion: 3,
breathing: 3, pelvis: 3, coreControl: 3, hipMobility: 3, singleLeg: 3,
},
subjectiveTension: 4,
coachSummary: 'progress good',
trainingFocus: 'stability',
phaseGoal: 'next stage',
photoAngle: null,
})
prisma.reassessmentTodo.findFirst.mockResolvedValue({ id: 'todo-4', lessonCheckpoint: 4 })
prisma.reassessmentTodo.update.mockResolvedValue({ id: 'todo-4', completedAt: new Date() })
const result = await service.createSession('u1', 'op1', {
kind: ProfessionalAssessmentKind.FOLLOW_UP,
recordedAt: today,
observations: {
headPosition: 3, shoulderPosition: 3, thoracicExtension: 3, shoulderFlexion: 3,
breathing: 3, pelvis: 3, coreControl: 3, hipMobility: 3, singleLeg: 3,
},
coachSummary: 'progress good',
trainingFocus: 'stability',
phaseGoal: 'next stage',
})
expect(result.id).toBe('sess-2')
expect(prisma.reassessmentTodo.findFirst).toHaveBeenCalledWith({
where: { userId: 'u1', completedAt: null },
orderBy: { lessonCheckpoint: 'asc' },
})
expect(prisma.reassessmentTodo.update).toHaveBeenCalledWith({
where: { id: 'todo-4' },
data: { completedAt: expect.any(Date) },
})
})
it('generates baseline share card for single session without false comparison', async () => {
const sessDate = new Date('2026-09-01T00:00:00.000Z')
prisma.professionalAssessmentSession.findMany.mockResolvedValue([
{
id: 'sess-1',
userId: 'u1',
kind: ProfessionalAssessmentKind.INITIAL,
protocolVersion: 'offline-v1',
recordedAt: sessDate,
bookingId: null,
originAssessmentId: null,
observations: {
headPosition: 3, shoulderPosition: 3, thoracicExtension: 3, shoulderFlexion: 3,
breathing: 3, pelvis: 3, coreControl: 3, hipMobility: 3, singleLeg: 3,
},
subjectiveTension: 6,
coachSummary: 'initial summary',
trainingFocus: 'focus',
phaseGoal: 'goal',
photoAngle: null,
},
])
prisma.booking.count.mockResolvedValue(1)
prisma.trainingPlan.findFirst.mockResolvedValue({ id: 'p1', weeks: 12 })
prisma.growthShareCard.create.mockImplementation(async ({ data }: { data: any }) => ({
id: 'card-1',
...data,
}))
const card = await service.createShareCard('u1', false)
expect(card.title).toBe('我的第 1 节普拉提')
expect(card.caption).toContain('已建立初始身体基准状态')
expect(prisma.growthShareCard.create.mock.calls[0][0].data.caption).not.toContain('周前肩颈紧张')
})
})

View File

@@ -0,0 +1,146 @@
import { ForbiddenException } from '@nestjs/common'
import { BodyPortraitAssessmentStatus, GrowthEventName, GrowthLeadStage, PortraitGoal, SafetyFlag, SittingHours } from '@mp-pilates/shared'
import { BodyPortraitService } from '../body-portrait.service'
import { BodyPortraitLifecycleService } from '../body-portrait-lifecycle.service'
import { PrismaService } from '../../prisma/prisma.service'
import { hashToken } from '../body-portrait.token'
describe('BodyPortraitService', () => {
const prisma: any = {
bodyPortraitVisit: { create: jest.fn(), findUnique: jest.fn(), update: jest.fn() },
bodyPortraitAssessment: {
create: jest.fn(),
findFirst: jest.fn(),
findUnique: jest.fn(),
findUniqueOrThrow: jest.fn(),
update: jest.fn(),
updateMany: jest.fn(),
count: jest.fn(),
},
growthEvent: { create: jest.fn() },
growthLead: { findUnique: jest.fn(), create: jest.fn(), update: jest.fn() },
user: { findFirst: jest.fn(), findUnique: jest.fn() },
$transaction: jest.fn(),
}
const lifecycle = new BodyPortraitLifecycleService(prisma as unknown as PrismaService)
let service: BodyPortraitService
beforeEach(() => {
jest.resetAllMocks()
prisma.growthEvent.create.mockResolvedValue({})
prisma.growthLead.findUnique.mockResolvedValue(null)
service = new BodyPortraitService(prisma as unknown as PrismaService, lifecycle)
})
it('creates hashed visit tokens and does not store plaintext', async () => {
prisma.user.findFirst.mockResolvedValue({ id: 'ref' })
prisma.bodyPortraitVisit.create.mockImplementation(async ({ data }: { data: { tokenHash: string } }) => ({ id: 'visit', ...data }))
const result = await service.createVisit('1.1.1.1', { source: 'xhs', referralCode: 'AB12CD' })
expect(result.visitToken).toHaveLength(64)
expect(prisma.bodyPortraitVisit.create.mock.calls[0][0].data.tokenHash).toBe(hashToken(result.visitToken))
expect(prisma.bodyPortraitVisit.create.mock.calls[0][0].data.tokenHash).not.toBe(result.visitToken)
expect(prisma.bodyPortraitVisit.create.mock.calls[0][0].data.referrerUserId).toBe('ref')
})
it('refuses to claim another user assessment', async () => {
prisma.bodyPortraitAssessment.findUnique.mockResolvedValue({
id: 'a1',
userId: 'other',
status: BodyPortraitAssessmentStatus.COMPLETED,
report: { scores: {} },
visitId: 'v1',
answers: { goal: PortraitGoal.POSTURE, sittingHours: SittingHours.GT8, safety: [SafetyFlag.NONE] },
})
await expect(service.claim('me', 'token-token-token-token-token-token-token')).rejects.toThrow(ForbiddenException)
})
it('completes with server-side scores and ignores client-sent scores', async () => {
prisma.bodyPortraitAssessment.findUnique.mockResolvedValue({
id: 'a1',
userId: null,
visitId: 'v1',
status: BodyPortraitAssessmentStatus.DRAFT,
expiresAt: new Date(Date.now() + 10000),
completedAt: null,
answers: {
concerns: ['neck'],
goal: 'neckRelief',
sittingHours: 'gt8',
exerciseFreq: 'none',
workPosture: 'computer',
endOfDayFatigue: ['neck'],
afterSitting: ['neckTight'],
standingNotice: ['headForward'],
safety: ['none'],
},
})
prisma.bodyPortraitAssessment.update.mockImplementation(async ({ data }: { data: Record<string, unknown> }) => ({
id: 'a1',
userId: null,
status: data.status,
answers: data.answers,
report: data.report,
}))
const session = await service.complete('token-token-token-token-token-token-token')
expect(session.teaser?.primaryType).toBeTruthy()
expect(session.report).toBeNull()
expect(prisma.bodyPortraitAssessment.update.mock.calls[0][0].data.report.scores.cervicalShoulder).toBeGreaterThan(0)
})
it('rejects saveAnswers on expired assessment', async () => {
prisma.bodyPortraitAssessment.findUnique.mockResolvedValue({
id: 'a1',
userId: null,
visitId: 'v1',
status: BodyPortraitAssessmentStatus.EXPIRED,
expiresAt: new Date(Date.now() - 1000),
answers: {},
})
await expect(service.saveAnswers('expired-token', { goal: PortraitGoal.CORE })).rejects.toThrow('测评已过期')
})
})
describe('BodyPortraitLifecycleService', () => {
const prisma: any = {
growthEvent: { create: jest.fn() },
growthLead: { findUnique: jest.fn(), create: jest.fn(), update: jest.fn() },
bodyPortraitAssessment: { findUnique: jest.fn(), findFirst: jest.fn() },
}
it('does not move stages backwards', async () => {
const service = new BodyPortraitLifecycleService(prisma as unknown as PrismaService)
prisma.growthLead.findUnique.mockResolvedValue({
userId: 'u1',
stage: GrowthLeadStage.PLAN_PURCHASED,
latestAssessmentId: 'a1',
})
await service.advanceLead('u1', GrowthLeadStage.CLAIMED, 'a1')
expect(prisma.growthLead.update.mock.calls[0][0].data.stage).toBe(GrowthLeadStage.PLAN_PURCHASED)
})
it('uses unique event keys for paid orders', async () => {
const service = new BodyPortraitLifecycleService(prisma as unknown as PrismaService)
prisma.growthEvent.create.mockResolvedValue({})
prisma.bodyPortraitAssessment.findFirst.mockResolvedValue({ id: 'a1', visitId: 'v1' })
prisma.growthLead.findUnique.mockResolvedValue({ userId: 'u1', stage: GrowthLeadStage.CLAIMED, latestAssessmentId: 'a1' })
await service.onOrderPaid('u1', 'TRIAL', 'order-1')
expect(prisma.growthEvent.create.mock.calls[0][0].data.idempotencyKey).toBe('trial_purchased:order-1')
expect(prisma.growthEvent.create.mock.calls[0][0].data.name).toBe(GrowthEventName.TRIAL_PURCHASED)
})
it('generates customized follow-up drafts with nickname', () => {
const service = new BodyPortraitLifecycleService(prisma as unknown as PrismaService)
const draft = service.followUpDraft({
nickname: '张同学',
report: null,
})
expect(draft).toContain('张同学你好,')
expect(draft).toContain('做一次实际的活动度和动作评估')
const trialDraft = service.followUpAfterTrialDraft({
nickname: '李同学',
report: null,
})
expect(trialDraft).toContain('李同学你好,前两天的普拉提体验课感觉怎么样?')
})
})

View File

@@ -0,0 +1,85 @@
import {
AfterSitting,
BodyRegion,
ExerciseFreq,
PortraitGoal,
PortraitType,
SafetyFlag,
SittingHours,
StandingNotice,
WorkPosture,
} from '@mp-pilates/shared'
import { isSafetyFlagged, scorePortrait } from '../scoring/score-v1'
const base = {
concerns: [BodyRegion.SHOULDER],
goal: PortraitGoal.POSTURE,
sittingHours: SittingHours.H6_TO_8,
exerciseFreq: ExerciseFreq.W1_TO_2,
workPosture: WorkPosture.COMPUTER,
endOfDayFatigue: [BodyRegion.NECK],
afterSitting: [AfterSitting.NECK_TIGHT],
standingNotice: [StandingNotice.HEAD_FORWARD],
safety: [SafetyFlag.NONE],
}
describe('scorePortrait v1', () => {
const fixtures: Array<{ name: string; answers: typeof base; type: PortraitType; safety?: boolean; minCervical?: number }> = [
{ name: '久坐肩颈紧张', answers: { ...base, sittingHours: SittingHours.GT8, standingNotice: [StandingNotice.HEAD_FORWARD, StandingNotice.ROUNDED_SHOULDERS] }, type: PortraitType.SEDENTARY_NECK_TENSION, minCervical: 70 },
{ name: '久坐核心弱化', answers: { ...base, concerns: [BodyRegion.LOW_BACK], goal: PortraitGoal.CORE, afterSitting: [AfterSitting.LOW_BACK_ACHE], standingNotice: [StandingNotice.BELLY_FORWARD], endOfDayFatigue: [BodyRegion.LOW_BACK] }, type: PortraitType.SEDENTARY_CORE_WEAK },
{ name: '肩颈代偿', answers: { ...base, sittingHours: SittingHours.LT4, workPosture: WorkPosture.WALKING, exerciseFreq: ExerciseFreq.W3_TO_4 }, type: PortraitType.NECK_COMPENSATION },
{ name: '髋部紧张', answers: { ...base, concerns: [BodyRegion.HIP, BodyRegion.PELVIS], goal: PortraitGoal.HIP_LEG, afterSitting: [AfterSitting.HIP_TIGHT], standingNotice: [StandingNotice.UNNOTICED], endOfDayFatigue: [BodyRegion.HIP], workPosture: WorkPosture.WALKING }, type: PortraitType.HIP_TIGHT },
{ name: '下肢稳定不足', answers: { ...base, concerns: [BodyRegion.KNEE, BodyRegion.LEG], goal: PortraitGoal.STABILITY, afterSitting: [AfterSitting.NONE], standingNotice: [StandingNotice.LOCKED_KNEES], endOfDayFatigue: [BodyRegion.KNEE], sittingHours: SittingHours.LT4, workPosture: WorkPosture.STANDING }, type: PortraitType.LOWER_LIMB_UNSTABLE },
{ name: '上下交叉', answers: { ...base, concerns: [BodyRegion.NECK, BodyRegion.SHOULDER, BodyRegion.LOW_BACK, BodyRegion.HIP], goal: PortraitGoal.POSTURE, sittingHours: SittingHours.GT8, exerciseFreq: ExerciseFreq.NONE, afterSitting: [AfterSitting.NECK_TIGHT, AfterSitting.LOW_BACK_ACHE, AfterSitting.HIP_TIGHT], standingNotice: [StandingNotice.ROUNDED_SHOULDERS, StandingNotice.BELLY_FORWARD], endOfDayFatigue: [BodyRegion.NECK, BodyRegion.LOW_BACK] }, type: PortraitType.UPPER_LOWER_CROSS },
{ name: '整体关注较低', answers: { ...base, concerns: [], goal: PortraitGoal.OTHER, sittingHours: SittingHours.LT4, exerciseFreq: ExerciseFreq.W5_PLUS, workPosture: WorkPosture.WALKING, endOfDayFatigue: [], afterSitting: [AfterSitting.NONE], standingNotice: [StandingNotice.UNNOTICED] }, type: PortraitType.LOW_OVERALL_CONCERN },
{ name: '全身疲劳', answers: { ...base, concerns: [BodyRegion.NECK, BodyRegion.LOW_BACK, BodyRegion.HIP, BodyRegion.KNEE], goal: PortraitGoal.OTHER, sittingHours: SittingHours.H4_TO_6, exerciseFreq: ExerciseFreq.NONE, workPosture: WorkPosture.HOLDING_CHILD, afterSitting: [AfterSitting.NECK_TIGHT, AfterSitting.BACK_STIFF, AfterSitting.HIP_TIGHT], standingNotice: [StandingNotice.UNEVEN_SHOULDERS], endOfDayFatigue: [BodyRegion.SHOULDER, BodyRegion.LEG] }, type: PortraitType.NECK_COMPENSATION },
{ name: '低头手机', answers: { ...base, workPosture: WorkPosture.PHONE, sittingHours: SittingHours.GT8 }, type: PortraitType.SEDENTARY_NECK_TENSION },
{ name: '抱娃', answers: { ...base, concerns: [BodyRegion.LOW_BACK, BodyRegion.SHOULDER], workPosture: WorkPosture.HOLDING_CHILD, goal: PortraitGoal.BACK_COMFORT, afterSitting: [AfterSitting.LOW_BACK_ACHE], standingNotice: [StandingNotice.BELLY_FORWARD] }, type: PortraitType.SEDENTARY_CORE_WEAK },
{ name: '塑形目标', answers: { ...base, goal: PortraitGoal.SHAPING, concerns: [BodyRegion.LOW_BACK], afterSitting: [AfterSitting.LOW_BACK_ACHE], standingNotice: [StandingNotice.BELLY_FORWARD] }, type: PortraitType.SEDENTARY_CORE_WEAK },
{ name: '腰背久坐舒服', answers: { ...base, goal: PortraitGoal.BACK_COMFORT, concerns: [BodyRegion.UPPER_BACK], afterSitting: [AfterSitting.BACK_STIFF], standingNotice: [StandingNotice.ROUNDED_SHOULDERS] }, type: PortraitType.SEDENTARY_CORE_WEAK },
{ name: '运动很少的肩颈', answers: { ...base, exerciseFreq: ExerciseFreq.NONE, sittingHours: SittingHours.H6_TO_8 }, type: PortraitType.SEDENTARY_NECK_TENSION },
{ name: '膝腿久站', answers: { ...base, concerns: [BodyRegion.KNEE], goal: PortraitGoal.STABILITY, workPosture: WorkPosture.STANDING, sittingHours: SittingHours.LT4, afterSitting: [AfterSitting.NONE], standingNotice: [StandingNotice.LOCKED_KNEES], endOfDayFatigue: [BodyRegion.LEG] }, type: PortraitType.LOWER_LIMB_UNSTABLE },
{ name: '骨盆困扰', answers: { ...base, concerns: [BodyRegion.PELVIS], goal: PortraitGoal.HIP_LEG, afterSitting: [AfterSitting.HIP_TIGHT], standingNotice: [StandingNotice.BELLY_FORWARD], endOfDayFatigue: [BodyRegion.PELVIS], workPosture: WorkPosture.WALKING, sittingHours: SittingHours.H4_TO_6 }, type: PortraitType.HIP_TIGHT },
{ name: '上背僵硬', answers: { ...base, concerns: [BodyRegion.UPPER_BACK], afterSitting: [AfterSitting.BACK_STIFF], standingNotice: [StandingNotice.ROUNDED_SHOULDERS] }, type: PortraitType.NECK_COMPENSATION },
{ name: '肩颈目标明确', answers: { ...base, goal: PortraitGoal.NECK_RELIEF, concerns: [BodyRegion.NECK, BodyRegion.SHOULDER] }, type: PortraitType.SEDENTARY_NECK_TENSION },
{ name: '安全分流不计分仍出类型', answers: { ...base, safety: [SafetyFlag.PERSISTENT_PAIN] }, type: PortraitType.SEDENTARY_NECK_TENSION, safety: true },
{ name: '医生限制运动', answers: { ...base, safety: [SafetyFlag.DOCTOR_LIMIT, SafetyFlag.RECENT_INJURY] }, type: PortraitType.SEDENTARY_NECK_TENSION, safety: true },
{ name: '麻木分流', answers: { ...base, safety: [SafetyFlag.NUMBNESS] }, type: PortraitType.SEDENTARY_NECK_TENSION, safety: true },
{ name: '医疗恢复期', answers: { ...base, safety: [SafetyFlag.MEDICAL_RECOVERY] }, type: PortraitType.SEDENTARY_NECK_TENSION, safety: true },
{ name: '高频运动降低关注', answers: { ...base, exerciseFreq: ExerciseFreq.W5_PLUS, sittingHours: SittingHours.H4_TO_6, afterSitting: [AfterSitting.NONE], standingNotice: [StandingNotice.UNNOTICED] }, type: PortraitType.LOW_OVERALL_CONCERN },
]
it.each(fixtures)('$name', ({ answers, type, safety, minCervical }) => {
const report = scorePortrait(answers)
expect(report.primaryType).toBe(type)
expect(report.safety.flagged).toBe(!!safety)
expect(isSafetyFlagged(answers)).toBe(!!safety)
expect(report.advice === null).toBe(!!safety)
if (minCervical) expect(report.scores.cervicalShoulder).toBeGreaterThanOrEqual(minCervical)
expect(report.scores.cervicalShoulder).toBeLessThanOrEqual(100)
expect(report.headline.length).toBeGreaterThan(0)
expect(report.summary.includes('诊断')).toBe(false)
expect(report.summary.includes('准确率')).toBe(false)
})
it('clamps scores and ignores unknown answers', () => {
const report = scorePortrait({
...base,
concerns: ['alien' as never, BodyRegion.NECK, BodyRegion.NECK],
sittingHours: SittingHours.GT8,
exerciseFreq: ExerciseFreq.NONE,
afterSitting: [AfterSitting.NECK_TIGHT, AfterSitting.BACK_STIFF, AfterSitting.LOW_BACK_ACHE, AfterSitting.HIP_TIGHT],
standingNotice: [StandingNotice.HEAD_FORWARD, StandingNotice.ROUNDED_SHOULDERS, StandingNotice.BELLY_FORWARD, StandingNotice.LOCKED_KNEES],
})
for (const value of Object.values(report.scores)) {
expect(value).toBeGreaterThanOrEqual(0)
expect(value).toBeLessThanOrEqual(100)
}
expect(report.evidence.length).toBeLessThanOrEqual(3)
})
it('does not treat none safety as flagged', () => {
expect(scorePortrait(base).safety.flagged).toBe(false)
expect(scorePortrait({ ...base, safety: [] }).safety.flagged).toBe(false)
})
})

View File

@@ -0,0 +1,80 @@
import { Body, Controller, Get, Param, Post, Put, 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 { BodyPortraitAdminService } from './body-portrait-admin.service'
import { BodyPortraitOfflineService } from './body-portrait-offline.service'
import { CreateProfessionalAssessmentDto, CreateTrainingPlanDto, UpdateLeadNoteDto } from './dto/body-portrait.dto'
@Controller('admin')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
export class BodyPortraitAdminController {
constructor(
private readonly admin: BodyPortraitAdminService,
private readonly offline: BodyPortraitOfflineService,
) {}
@Get('growth/today')
today() {
return this.admin.dashboard()
}
@Get('growth/leads')
leads(
@Query('page') page?: string,
@Query('limit') limit?: string,
@Query('search') search?: string,
@Query('stage') stage?: string,
@Query('source') source?: string,
) {
return this.admin.listLeads({
page: page ? Number(page) : 1,
limit: limit ? Number(limit) : 20,
search,
stage,
source,
})
}
@Get('growth/leads/:id')
detail(@Param('id') id: string) {
return this.admin.leadDetail(id)
}
@Put('growth/leads/:id/note')
note(@Param('id') id: string, @Body() dto: UpdateLeadNoteDto) {
return this.admin.updateNote(id, dto.coachNote)
}
@Get('members/:userId/professional-assessments')
sessions(@Param('userId') userId: string) {
return this.offline.listSessions(userId)
}
@Post('members/:userId/professional-assessments')
createSession(
@Param('userId') userId: string,
@CurrentUser('sub') operatorId: string,
@Body() dto: CreateProfessionalAssessmentDto,
) {
return this.offline.createSession(userId, operatorId, dto)
}
@Get('members/:userId/training-plans')
plans(@Param('userId') userId: string) {
return this.offline.listPlans(userId)
}
@Post('members/:userId/training-plans')
createPlan(@Param('userId') userId: string, @Body() dto: CreateTrainingPlanDto) {
return this.offline.createPlan(userId, dto)
}
@Get('members/:userId/reassessment-todos')
todos(@Param('userId') userId: string) {
return this.offline.todos(userId)
}
}

View File

@@ -0,0 +1,271 @@
import { Injectable, NotFoundException } from '@nestjs/common'
import {
BODY_DIMENSION_LABELS,
BodyDimension,
BodyPortraitAnswers,
BodyPortraitReport,
BodyPortraitSource,
GrowthEventName,
GrowthFunnelRow,
GrowthLeadDetail,
GrowthLeadStage,
GrowthLeadSummary,
GrowthTodayDashboard,
GrowthTodayTask,
} from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
import { BodyPortraitLifecycleService } from './body-portrait-lifecycle.service'
import { normalizeAnswers } from './scoring/score-v1'
const TERMINAL = new Set<string>([
GrowthLeadStage.TRIAL_BOOKED,
GrowthLeadStage.TRIAL_ATTENDED,
GrowthLeadStage.PLAN_PURCHASED,
GrowthLeadStage.TRAINING,
GrowthLeadStage.RENEWAL_DUE,
GrowthLeadStage.RENEWED,
])
@Injectable()
export class BodyPortraitAdminService {
constructor(
private readonly prisma: PrismaService,
private readonly lifecycle: BodyPortraitLifecycleService,
) {}
async dashboard(): Promise<GrowthTodayDashboard> {
const [leads, todos, funnel] = await Promise.all([
this.prisma.growthLead.findMany({
include: {
user: { select: { nickname: true, phone: true } },
latestAssessment: { include: { visit: true } },
},
orderBy: { lastActiveAt: 'desc' },
take: 80,
}),
this.prisma.reassessmentTodo.findMany({
where: { completedAt: null, dueAt: { lte: new Date() } },
include: { user: { select: { nickname: true } } },
take: 20,
}),
this.funnel(),
])
const tasks: GrowthTodayTask[] = []
const twoDaysAgo = Date.now() - 2 * 24 * 60 * 60 * 1000
for (const lead of leads) {
const report = (lead.latestAssessment?.report || null) as BodyPortraitReport | null
const answers = normalizeAnswers(lead.latestAssessment?.answers as Partial<BodyPortraitAnswers>)
const draft = this.lifecycle.followUpDraft({
nickname: lead.user.nickname,
report,
sittingHours: answers.sittingHours,
workPosture: answers.workPosture,
})
if (!TERMINAL.has(lead.stage) && lead.latestAssessment?.completedAt && ['CLAIMED', 'PHONE_BOUND', 'COMPLETED'].includes(lead.stage)) {
const top = report ? this.topDimension(report) : null
tasks.push({
kind: 'completed_unbooked',
leadId: lead.id,
userId: lead.userId,
nickname: lead.user.nickname || '未命名',
title: `${lead.user.nickname || '学员'}完成身体测试但未预约`,
detail: top ? `${BODY_DIMENSION_LABELS[top[0]]}关注度 ${top[1]}` : '已完成线上画像',
happenedAt: lead.lastActiveAt.toISOString(),
followUpDraft: draft,
})
}
if (lead.stage === GrowthLeadStage.TRIAL_ATTENDED && lead.lastActiveAt.getTime() <= twoDaysAgo) {
tasks.push({
kind: 'trial_unpurchased',
leadId: lead.id,
userId: lead.userId,
nickname: lead.user.nickname || '未命名',
title: `${lead.user.nickname || '学员'}完成体验课后未购买`,
detail: '建议回访,确认是否需要 12 周改善计划',
happenedAt: lead.lastActiveAt.toISOString(),
followUpDraft: this.lifecycle.followUpAfterTrialDraft({
nickname: lead.user.nickname,
report,
}),
})
}
}
for (const todo of todos) {
tasks.push({
kind: 'reassessment_due',
leadId: todo.planId,
userId: todo.userId,
nickname: todo.user.nickname || '未命名',
title: `${todo.user.nickname || '学员'}到达第 ${todo.lessonCheckpoint} 节复测节点`,
detail: '建议安排阶段复测',
happenedAt: todo.dueAt.toISOString(),
followUpDraft: `训练已经走到第 ${todo.lessonCheckpoint} 节附近了,可以安排一次阶段复测,看看身体状态有没有变化。`,
})
}
return { tasks: tasks.slice(0, 12), funnel }
}
async listLeads(query: { page?: number; limit?: number; search?: string; stage?: string; source?: string }) {
const page = Math.max(1, Number(query.page) || 1)
const limit = Math.min(50, Math.max(1, Number(query.limit) || 20))
const where: Record<string, unknown> = {}
if (query.stage) where.stage = query.stage
if (query.source) where.source = query.source
if (query.search) {
where.user = {
OR: [
{ nickname: { contains: query.search } },
{ phone: { contains: query.search } },
],
}
}
const [items, total] = await Promise.all([
this.prisma.growthLead.findMany({
where,
include: { user: { select: { nickname: true, phone: true } }, latestAssessment: true },
orderBy: { lastActiveAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
this.prisma.growthLead.count({ where }),
])
return {
items: items.map((lead) => this.toSummary(lead)),
total,
page,
limit,
}
}
async leadDetail(id: string): Promise<GrowthLeadDetail> {
const lead = await this.prisma.growthLead.findUnique({
where: { id },
include: {
user: { select: { nickname: true, phone: true } },
latestAssessment: { include: { visit: true } },
},
})
if (!lead) throw new NotFoundException('线索不存在')
const events = await this.prisma.growthEvent.findMany({
where: { userId: lead.userId },
orderBy: { occurredAt: 'asc' },
take: 50,
})
const report = (lead.latestAssessment?.report || null) as BodyPortraitReport | null
const answers = lead.latestAssessment
? normalizeAnswers(lead.latestAssessment.answers as Partial<BodyPortraitAnswers>)
: null
const summary = this.toSummary(lead)
return {
...summary,
goalLabel: report?.goalLabel || null,
scores: report?.scores || null,
levels: report?.levels || null,
evidence: report?.evidence || [],
answers,
report,
events: events.map((event) => ({
id: event.id,
name: event.name as GrowthEventName,
occurredAt: event.occurredAt.toISOString(),
})),
coachNote: lead.coachNote,
firstAssessmentHints: report?.firstAssessmentHints || [],
}
}
async updateNote(id: string, coachNote: string) {
const lead = await this.prisma.growthLead.findUnique({ where: { id } })
if (!lead) throw new NotFoundException('线索不存在')
await this.prisma.growthLead.update({ where: { id }, data: { coachNote: coachNote.slice(0, 500) } })
return this.leadDetail(id)
}
private async funnel(): Promise<GrowthFunnelRow[]> {
const visits = await this.prisma.bodyPortraitVisit.findMany({
select: { id: true, source: true },
})
const byVisit = new Map(visits.map((visit) => [visit.id, visit.source as BodyPortraitSource]))
const events = await this.prisma.growthEvent.findMany({
select: { name: true, visitId: true, userId: true },
})
const rows = new Map<BodyPortraitSource, { source: BodyPortraitSource; started: number; completed: number; claimed: number; booked: number; attended: number; purchased: number }>()
const ensure = (source: BodyPortraitSource) => {
const current = rows.get(source) || {
source, started: 0, completed: 0, claimed: 0, booked: 0, attended: 0, purchased: 0,
}
rows.set(source, current)
return current
}
for (const source of Object.values(BodyPortraitSource)) ensure(source)
for (const visit of visits) ensure(visit.source as BodyPortraitSource).started += 0
const counted = new Set<string>()
for (const event of events) {
const source = (event.visitId && byVisit.get(event.visitId)) || BodyPortraitSource.ORGANIC
const row = ensure(source)
const key = `${source}:${event.name}:${event.userId || event.visitId}`
if (counted.has(key)) continue
counted.add(key)
if (event.name === GrowthEventName.ASSESSMENT_STARTED) row.started += 1
if (event.name === GrowthEventName.ASSESSMENT_COMPLETED) row.completed += 1
if (event.name === GrowthEventName.ASSESSMENT_CLAIMED) row.claimed += 1
if (event.name === GrowthEventName.TRIAL_BOOKED) row.booked += 1
if (event.name === GrowthEventName.TRIAL_ATTENDED) row.attended += 1
if (event.name === GrowthEventName.PLAN_PURCHASED) row.purchased += 1
}
return [...rows.values()]
}
private toSummary(lead: {
id: string
userId: string
stage: string
source: string | null
campaignId: string | null
lastActiveAt: Date
user: { nickname: string; phone: string | null }
latestAssessment: { completedAt: Date | null; safetyFlagged: boolean; report: unknown } | null
}): GrowthLeadSummary {
const report = (lead.latestAssessment?.report || null) as BodyPortraitReport | null
const top = report ? this.topDimension(report) : null
const answers = lead.latestAssessment
? normalizeAnswers((lead.latestAssessment as { answers?: unknown }).answers as Partial<BodyPortraitAnswers>)
: EMPTY_SAFE
return {
id: lead.id,
userId: lead.userId,
nickname: lead.user.nickname || '未命名',
phone: lead.user.phone,
stage: lead.stage as GrowthLeadStage,
source: (lead.source as BodyPortraitSource | null) || null,
campaignId: lead.campaignId,
primaryType: report?.primaryType || null,
topDimension: top?.[0] || null,
safetyFlagged: lead.latestAssessment?.safetyFlagged || false,
lastActiveAt: lead.lastActiveAt.toISOString(),
completedAt: lead.latestAssessment?.completedAt?.toISOString() || null,
followUpDraft: this.lifecycle.followUpDraft({
nickname: lead.user.nickname,
report,
sittingHours: answers.sittingHours,
workPosture: answers.workPosture,
}),
}
}
private topDimension(report: BodyPortraitReport): [BodyDimension, number] {
return (Object.entries(report.scores) as [BodyDimension, number][]).sort((a, b) => b[1] - a[1])[0]
}
}
const EMPTY_SAFE: BodyPortraitAnswers = {
concerns: [],
goal: null,
sittingHours: null,
exerciseFreq: null,
workPosture: null,
endOfDayFatigue: [],
afterSitting: [],
standingNotice: [],
safety: [],
}

View File

@@ -0,0 +1,218 @@
import { Injectable, Logger } from '@nestjs/common'
import {
BODY_DIMENSION_LABELS,
BodyDimension,
BodyPortraitReport,
BodyPortraitSource,
CardTypeCategory,
GrowthEventName,
GrowthLeadStage,
PORTRAIT_TYPE_LABELS,
SITTING_HOURS_LABELS,
SittingHours,
WORK_POSTURE_LABELS,
WorkPosture,
} from '@mp-pilates/shared'
import { Prisma } from '@prisma/client'
import { PrismaService } from '../prisma/prisma.service'
const STAGE_RANK: Record<GrowthLeadStage, number> = {
[GrowthLeadStage.VISIT]: 0,
[GrowthLeadStage.STARTED]: 1,
[GrowthLeadStage.COMPLETED]: 2,
[GrowthLeadStage.CLAIMED]: 3,
[GrowthLeadStage.PHONE_BOUND]: 4,
[GrowthLeadStage.TRIAL_PURCHASED]: 5,
[GrowthLeadStage.TRIAL_BOOKED]: 6,
[GrowthLeadStage.TRIAL_ATTENDED]: 7,
[GrowthLeadStage.PLAN_PURCHASED]: 8,
[GrowthLeadStage.TRAINING]: 9,
[GrowthLeadStage.RENEWAL_DUE]: 10,
[GrowthLeadStage.RENEWED]: 11,
}
const SAFE_EVENT_PROPS = new Set(['source', 'campaignId', 'cardType', 'bookingId', 'orderId', 'checkpoint'])
@Injectable()
export class BodyPortraitLifecycleService {
private readonly logger = new Logger(BodyPortraitLifecycleService.name)
constructor(private readonly prisma: PrismaService) {}
async recordEvent(input: {
name: GrowthEventName
idempotencyKey: string
visitId?: string | null
assessmentId?: string | null
userId?: string | null
properties?: Record<string, unknown>
stage?: GrowthLeadStage
}) {
const properties = this.sanitize(input.properties)
try {
await this.prisma.growthEvent.create({
data: {
name: input.name,
idempotencyKey: input.idempotencyKey.slice(0, 120),
visitId: input.visitId || undefined,
assessmentId: input.assessmentId || undefined,
userId: input.userId || undefined,
properties: properties as Prisma.InputJsonValue,
},
})
} catch (error) {
if (this.isUnique(error)) return
this.logger.warn(`growth event failed: ${input.name}`)
return
}
if (input.userId && input.stage) {
await this.advanceLead(input.userId, input.stage, input.assessmentId)
}
}
async advanceLead(userId: string, stage: GrowthLeadStage, assessmentId?: string | null) {
const existing = await this.prisma.growthLead.findUnique({ where: { userId } })
if (!existing) {
if (STAGE_RANK[stage] < STAGE_RANK[GrowthLeadStage.CLAIMED]) return
const assessment = assessmentId
? await this.prisma.bodyPortraitAssessment.findUnique({ where: { id: assessmentId }, include: { visit: true } })
: await this.prisma.bodyPortraitAssessment.findFirst({ where: { userId }, orderBy: { createdAt: 'desc' }, include: { visit: true } })
await this.prisma.growthLead.create({
data: {
userId,
latestAssessmentId: assessment?.id,
stage,
source: assessment?.visit.source,
campaignId: assessment?.visit.campaignId,
lastActiveAt: new Date(),
},
})
return
}
const nextStage = STAGE_RANK[stage] > STAGE_RANK[existing.stage as GrowthLeadStage] ? stage : existing.stage
await this.prisma.growthLead.update({
where: { userId },
data: {
stage: nextStage,
lastActiveAt: new Date(),
latestAssessmentId: assessmentId || existing.latestAssessmentId,
},
})
}
async onPhoneBound(userId: string) {
await this.recordEvent({
name: GrowthEventName.PHONE_BOUND,
idempotencyKey: `phone_bound:${userId}`,
userId,
stage: GrowthLeadStage.PHONE_BOUND,
})
}
async onOrderPaid(userId: string, cardType: string, orderId: string) {
const assessment = await this.latestAssessment(userId)
const trial = cardType === CardTypeCategory.TRIAL
await this.recordEvent({
name: trial ? GrowthEventName.TRIAL_PURCHASED : GrowthEventName.PLAN_PURCHASED,
idempotencyKey: `${trial ? 'trial_purchased' : 'plan_purchased'}:${orderId}`,
userId,
assessmentId: assessment?.id,
visitId: assessment?.visitId,
properties: { cardType, orderId },
stage: trial ? GrowthLeadStage.TRIAL_PURCHASED : GrowthLeadStage.PLAN_PURCHASED,
})
if (!trial) {
await this.advanceLead(userId, GrowthLeadStage.TRAINING, assessment?.id)
}
}
async onBookingCreated(userId: string, bookingId: string, cardType: string, originAssessmentId?: string | null) {
if (cardType !== CardTypeCategory.TRIAL) return
const assessmentId = originAssessmentId || (await this.latestAssessment(userId))?.id
const assessment = assessmentId
? await this.prisma.bodyPortraitAssessment.findUnique({ where: { id: assessmentId } })
: null
await this.recordEvent({
name: GrowthEventName.TRIAL_BOOKED,
idempotencyKey: `trial_booked:${bookingId}`,
userId,
assessmentId: assessment?.id,
visitId: assessment?.visitId,
properties: { bookingId },
stage: GrowthLeadStage.TRIAL_BOOKED,
})
}
async onBookingCompleted(userId: string, bookingId: string, cardType: string) {
if (cardType !== CardTypeCategory.TRIAL) return
const assessment = await this.latestAssessment(userId)
await this.recordEvent({
name: GrowthEventName.TRIAL_ATTENDED,
idempotencyKey: `trial_attended:${bookingId}`,
userId,
assessmentId: assessment?.id,
visitId: assessment?.visitId,
properties: { bookingId },
stage: GrowthLeadStage.TRIAL_ATTENDED,
})
}
followUpDraft(input: {
nickname: string
report: BodyPortraitReport | null
sittingHours?: SittingHours | null
workPosture?: WorkPosture | null
}): string {
const greeting = input.nickname ? `${input.nickname}你好,` : ''
if (!input.report) {
return `${greeting}看到你刚刚做了身体状态评估。线上只能做基础判断,如果方便的话,可以来工作室做一次实际的活动度和动作评估,我可以再帮你具体看看。`
}
const top = (Object.entries(input.report.scores) as [BodyDimension, number][])
.sort((a, b) => b[1] - a[1])[0]
const lifestyle = [
input.workPosture ? WORK_POSTURE_LABELS[input.workPosture] : '',
input.sittingHours ? SITTING_HOURS_LABELS[input.sittingHours] : '',
].filter(Boolean).join('、')
const type = PORTRAIT_TYPE_LABELS[input.report.primaryType]
const dimension = BODY_DIMENSION_LABELS[top[0]]
return `${greeting}看到你刚刚做了身体状态评估,你目前比较明显的是${dimension}相关的关注点,画像更接近「${type}」。${lifestyle ? `如果平时${lifestyle}比较多,这种情况其实很常见。` : ''}线上只能做基础判断,如果方便的话,可以来工作室做一次实际的活动度和动作评估,我可以再帮你具体看看。`
}
followUpAfterTrialDraft(input: {
nickname: string
report: BodyPortraitReport | null
}): string {
const greeting = input.nickname ? `${input.nickname}你好,` : ''
const type = input.report ? `结合你之前的「${PORTRAIT_TYPE_LABELS[input.report.primaryType]}」画像和` : ''
return `${greeting}前两天的普拉提体验课感觉怎么样?${type}现场教练观察的情况,我们为你准备了针对性的阶段改善建议。如果有时间可以聊聊你的感受,看看是否需要为你规划后续的训练。`
}
parseSource(raw?: string | null): BodyPortraitSource {
const value = String(raw || '').toLowerCase()
return (Object.values(BodyPortraitSource) as string[]).includes(value)
? value as BodyPortraitSource
: BodyPortraitSource.ORGANIC
}
private async latestAssessment(userId: string) {
return this.prisma.bodyPortraitAssessment.findFirst({
where: { userId },
orderBy: { createdAt: 'desc' },
})
}
private sanitize(properties?: Record<string, unknown>) {
const result: Record<string, string> = {}
if (!properties) return result
for (const [key, value] of Object.entries(properties)) {
if (!SAFE_EVENT_PROPS.has(key)) continue
if (value == null) continue
result[key] = String(value).slice(0, 80)
}
return result
}
private isUnique(error: unknown) {
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002'
}
}

View File

@@ -0,0 +1,315 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'
import {
BODY_PORTRAIT_OFFLINE_PROTOCOL,
CreateProfessionalAssessmentDto,
CreateTrainingPlanDto,
GrowthShareCardRecord,
ProfessionalAssessmentKind,
ProfessionalAssessmentSessionRecord,
ProfessionalObservationScores,
ReassessmentTodoRecord,
TrainingPlanRecord,
TrainingPlanStatus,
} from '@mp-pilates/shared'
import { Prisma } from '@prisma/client'
import { PrismaService } from '../prisma/prisma.service'
import { createShareCode } from './body-portrait.token'
const DEFAULT_PHASES = [
{ name: 'Phase 1 · 重新建立控制', lessonStart: 1, lessonEnd: 4, focus: ['呼吸', '骨盆控制', '核心激活'], summary: '先找回呼吸、骨盆和核心的基本控制。', sortOrder: 0 },
{ name: 'Phase 2 · 改善活动能力', lessonStart: 5, lessonEnd: 8, focus: ['胸椎', '肩带', '髋'], summary: '在控制基础上改善上背、肩和髋的活动。', sortOrder: 1 },
{ name: 'Phase 3 · 整合身体动作', lessonStart: 9, lessonEnd: 12, focus: ['全身控制', '稳定', '日常动作迁移'], summary: '把训练能力带回站、走和日常动作。', sortOrder: 2 },
]
@Injectable()
export class BodyPortraitOfflineService {
constructor(private readonly prisma: PrismaService) {}
async createSession(userId: string, operatorId: string, dto: CreateProfessionalAssessmentDto): Promise<ProfessionalAssessmentSessionRecord> {
await this.ensureUser(userId)
if (dto.bookingId) {
const booking = await this.prisma.booking.findFirst({ where: { id: dto.bookingId, userId } })
if (!booking) throw new BadRequestException('只能关联该学员的预约')
}
if (dto.originAssessmentId) {
const assessment = await this.prisma.bodyPortraitAssessment.findFirst({ where: { id: dto.originAssessmentId, userId } })
if (!assessment) throw new BadRequestException('线上画像不属于该学员')
}
const recordedAt = this.date(dto.recordedAt)
const created = await this.prisma.professionalAssessmentSession.create({
data: {
userId,
kind: dto.kind,
protocolVersion: BODY_PORTRAIT_OFFLINE_PROTOCOL,
recordedAt,
bookingId: dto.bookingId,
originAssessmentId: dto.originAssessmentId,
observations: dto.observations as unknown as Prisma.InputJsonValue,
subjectiveTension: dto.subjectiveTension ?? null,
coachSummary: dto.coachSummary.trim().slice(0, 1000),
trainingFocus: dto.trainingFocus.trim().slice(0, 500),
phaseGoal: dto.phaseGoal.trim().slice(0, 500),
photoAngle: dto.photoAngle || null,
operatorId,
},
})
if (dto.kind === ProfessionalAssessmentKind.FOLLOW_UP) {
const nextTodo = await this.prisma.reassessmentTodo.findFirst({
where: { userId, completedAt: null },
orderBy: { lessonCheckpoint: 'asc' },
})
if (nextTodo) {
await this.prisma.reassessmentTodo.update({
where: { id: nextTodo.id },
data: { completedAt: new Date() },
})
}
}
return this.mapSession(created)
}
async listSessions(userId: string): Promise<ProfessionalAssessmentSessionRecord[]> {
const rows = await this.prisma.professionalAssessmentSession.findMany({
where: { userId },
orderBy: { recordedAt: 'asc' },
})
return rows.map((row) => this.mapSession(row))
}
async createPlan(userId: string, dto: CreateTrainingPlanDto): Promise<TrainingPlanRecord> {
await this.ensureUser(userId)
if (dto.sessionId) {
const session = await this.prisma.professionalAssessmentSession.findFirst({ where: { id: dto.sessionId, userId } })
if (!session) throw new BadRequestException('评估记录不属于该学员')
}
const plan = await this.prisma.trainingPlan.create({
data: {
userId,
sessionId: dto.sessionId,
title: dto.title?.trim() || '你的 12 周身体改善计划',
status: TrainingPlanStatus.ACTIVE,
weeks: dto.weeks || 12,
phases: {
create: DEFAULT_PHASES.map((phase) => ({
...phase,
focus: phase.focus as unknown as Prisma.InputJsonValue,
})),
},
},
include: { phases: { orderBy: { sortOrder: 'asc' } } },
})
const now = new Date()
await this.prisma.reassessmentTodo.createMany({
data: [4, 8, 12].map((checkpoint) => ({
userId,
planId: plan.id,
lessonCheckpoint: checkpoint,
dueAt: new Date(now.getTime() + checkpoint * 7 * 24 * 60 * 60 * 1000),
})),
})
return this.mapPlan(plan)
}
async listPlans(userId: string): Promise<TrainingPlanRecord[]> {
const plans = await this.prisma.trainingPlan.findMany({
where: { userId },
include: { phases: { orderBy: { sortOrder: 'asc' } } },
orderBy: { createdAt: 'desc' },
})
return plans.map((plan) => this.mapPlan(plan))
}
async todos(userId: string): Promise<ReassessmentTodoRecord[]> {
const rows = await this.prisma.reassessmentTodo.findMany({
where: { userId },
orderBy: { lessonCheckpoint: 'asc' },
})
return rows.map((row) => ({
id: row.id,
userId: row.userId,
planId: row.planId,
lessonCheckpoint: row.lessonCheckpoint,
dueAt: row.dueAt.toISOString(),
completedAt: row.completedAt?.toISOString() || null,
}))
}
async createShareCard(userId: string, includePhotos: boolean): Promise<GrowthShareCardRecord> {
const sessions = await this.listSessions(userId)
const first = sessions[0]
const latest = sessions[sessions.length - 1]
const hasComparison = sessions.length >= 2
const completedCount = await this.prisma.booking.count({ where: { userId, status: 'COMPLETED' } })
const plan = await this.prisma.trainingPlan.findFirst({ where: { userId }, orderBy: { createdAt: 'desc' } })
const rows = hasComparison ? this.compare(first, latest) : this.singleSnapshot(first)
let caption = '坚持有时候真的看得见。'
if (hasComparison && first?.subjectiveTension != null && latest?.subjectiveTension != null) {
caption = `${plan?.weeks || 12} 周前肩颈紧张 ${first.subjectiveTension}/10现在 ${latest.subjectiveTension}/10。坚持有时候真的看得见。`
} else if (!hasComparison && first?.subjectiveTension != null) {
caption = `已建立初始身体基准状态,肩颈紧张度 ${first.subjectiveTension}/10。开启专属改善计划。`
}
const created = await this.prisma.growthShareCard.create({
data: {
userId,
planId: plan?.id,
shareCode: createShareCode(),
title: `我的第 ${completedCount || 1} 节普拉提`,
caption,
completedCount,
weeks: plan?.weeks || 12,
rows: rows as unknown as Prisma.InputJsonValue,
includePhotos: includePhotos === true,
},
})
return this.mapShare(created)
}
async publicShare(shareCode: string) {
const card = await this.prisma.growthShareCard.findUnique({ where: { shareCode } })
if (!card) throw new NotFoundException('分享卡片不存在')
return {
...this.mapShare(card),
includePhotos: false,
}
}
private singleSnapshot(first?: ProfessionalAssessmentSessionRecord) {
if (!first) return []
const labels: { key: keyof ProfessionalObservationScores; label: string }[] = [
{ key: 'shoulderFlexion', label: '肩屈活动' },
{ key: 'singleLeg', label: '单腿稳定' },
{ key: 'coreControl', label: '核心控制' },
{ key: 'thoracicExtension', label: '胸椎活动' },
]
const rows = labels.map((item) => ({
label: item.label,
first: `${first.observations[item.key]}/5`,
latest: `${first.observations[item.key]}/5`,
}))
if (first.subjectiveTension != null) {
rows.push({
label: '肩颈主观紧张',
first: `${first.subjectiveTension}/10`,
latest: `${first.subjectiveTension}/10`,
})
}
return rows
}
private compare(first: ProfessionalAssessmentSessionRecord, latest: ProfessionalAssessmentSessionRecord) {
const labels: { key: keyof ProfessionalObservationScores; label: string }[] = [
{ key: 'shoulderFlexion', label: '肩屈活动' },
{ key: 'singleLeg', label: '单腿稳定' },
{ key: 'coreControl', label: '核心控制' },
{ key: 'thoracicExtension', label: '胸椎活动' },
]
const rows = labels.map((item) => ({
label: item.label,
first: `${first.observations[item.key]}/5`,
latest: `${latest.observations[item.key]}/5`,
}))
if (first.subjectiveTension != null || latest.subjectiveTension != null) {
rows.push({
label: '肩颈主观紧张',
first: first.subjectiveTension != null ? `${first.subjectiveTension}/10` : '—',
latest: latest.subjectiveTension != null ? `${latest.subjectiveTension}/10` : '—',
})
}
return rows
}
private mapSession(row: {
id: string
userId: string
kind: string
protocolVersion: string
recordedAt: Date
bookingId: string | null
originAssessmentId: string | null
observations: Prisma.JsonValue
subjectiveTension: number | null
coachSummary: string
trainingFocus: string
phaseGoal: string
photoAngle: string | null
}): ProfessionalAssessmentSessionRecord {
return {
id: row.id,
userId: row.userId,
kind: row.kind as ProfessionalAssessmentKind,
protocolVersion: row.protocolVersion,
recordedAt: row.recordedAt.toISOString().slice(0, 10),
bookingId: row.bookingId,
originAssessmentId: row.originAssessmentId,
observations: row.observations as unknown as ProfessionalObservationScores,
subjectiveTension: row.subjectiveTension,
coachSummary: row.coachSummary,
trainingFocus: row.trainingFocus,
phaseGoal: row.phaseGoal,
photoAngle: row.photoAngle as ProfessionalAssessmentSessionRecord['photoAngle'],
}
}
private mapPlan(plan: {
id: string
userId: string
title: string
status: string
weeks: number
sessionId: string | null
phases: Array<{ id: string; name: string; lessonStart: number; lessonEnd: number; focus: Prisma.JsonValue; summary: string }>
}): TrainingPlanRecord {
return {
id: plan.id,
userId: plan.userId,
title: plan.title,
status: plan.status as TrainingPlanStatus,
weeks: plan.weeks,
sessionId: plan.sessionId,
phases: plan.phases.map((phase) => ({
id: phase.id,
name: phase.name,
lessonStart: phase.lessonStart,
lessonEnd: phase.lessonEnd,
focus: phase.focus as string[],
summary: phase.summary,
})),
}
}
private mapShare(card: {
id: string
shareCode: string
title: string
caption: string
completedCount: number
weeks: number
rows: Prisma.JsonValue
includePhotos: boolean
}): GrowthShareCardRecord {
return {
id: card.id,
shareCode: card.shareCode,
title: card.title,
caption: card.caption,
completedCount: card.completedCount,
weeks: card.weeks,
rows: card.rows as unknown as GrowthShareCardRecord['rows'],
includePhotos: card.includePhotos,
}
}
private async ensureUser(userId: string) {
const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { id: true } })
if (!user) throw new NotFoundException('学员不存在')
}
private date(value: string) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) throw new BadRequestException('日期无效')
const date = new Date(`${value}T00:00:00.000Z`)
if (Number.isNaN(date.getTime())) throw new BadRequestException('日期无效')
const todayChina = new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10)
if (value > todayChina) throw new BadRequestException('日期不能晚于今天')
return date
}
}

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