Compare commits
10 Commits
3e049d2c1d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58c6d2092a | ||
|
|
b72357030d | ||
|
|
d91282ecbd | ||
|
|
ab6602e41d | ||
|
|
b8c0dd6781 | ||
|
|
806f3ee770 | ||
|
|
9af558286b | ||
|
|
99814f3720 | ||
|
|
014685ed04 | ||
|
|
9f5608f13c |
@@ -114,3 +114,9 @@ pnpm deploy:server # 部署后端到生产环境
|
||||
- 新增迁移目录只放 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
118
docs/body-portrait.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# 个人身体画像增长系统
|
||||
|
||||
入口:小程序「3 分钟身体状态评估」、报告页、体验预约,以及管理中心「今日经营助手」。线上获客归属独立 `body-portrait` 模块;线下专业评估与改善计划复用成长档案口径,不把问卷结果写成医疗诊断。
|
||||
|
||||
## 产品口径
|
||||
|
||||
- 对外名称:**3 分钟身体状态评估 / 个人身体画像**。只使用「关注度、可能表现、建议进一步评估」。禁止「诊断、疾病检测、患病风险、准确率」。
|
||||
- 分数 0~100 只表示**问卷关注度**,不表示身体能力、疼痛程度或患病概率。
|
||||
- 陌生用户可匿名完成问卷。完成后先看摘要;微信登录并认领后看完整报告。点击体验预约时必须授权手机号。
|
||||
- 安全分流不计分。命中持续明显疼痛、近期受伤、手脚明显麻木、医生限制运动或医疗恢复期时,不展示动作建议和强销售 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`。
|
||||
|
||||
| 分数 | 关注度 |
|
||||
| ---: | --- |
|
||||
| 0~30 | 低关注 |
|
||||
| 31~50 | 轻度关注 |
|
||||
| 51~70 | 中度关注 |
|
||||
| 71~85 | 高关注 |
|
||||
| 86~100 | 重点关注 |
|
||||
|
||||
规则版本 `portrait-r-v1`:先按答案累加,再把每一维 clamp 到 0~100。安全选项不加分。
|
||||
|
||||
## 画像模板(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 周计划三阶段:1–4 重新建立控制,5–8 改善活动能力,9–12 整合身体动作。卖的是改善计划,不是「20 节课」。
|
||||
|
||||
## 复测与分享(V3)
|
||||
|
||||
默认可在第 4 / 8 / 12 节生成复测待办。对比必须同协议、同观察项。成长卡片默认不含照片;公开导出需学员单次确认。分享落地进入 `source=member_share` 的新测评。
|
||||
|
||||
## 部署
|
||||
|
||||
新增表见 `packages/server/prisma/migrations/20260911180000_body_portrait/migration.sql`。发布顺序:`prisma migrate deploy` → 后端 → 小程序。回退先回退应用,表可保留。
|
||||
|
||||
## 验收清单
|
||||
|
||||
- 匿名可完成、可续填;登录后同一会话只能认领一次。
|
||||
- 完整报告需登录;体验预约需手机号。
|
||||
- 安全分流不计分、无动作建议、无强销售。
|
||||
- 分数与画像只来自服务端快照。
|
||||
- 馆主今日待办能看到「测完未约 / 体验后未购 / 待复测」。
|
||||
- 漏斗按渠道聚合;补录不进入获客转化。
|
||||
- 照片与分享遵守成长档案隐私规则。
|
||||
45
packages/app/src/components/BodySilhouette.vue
Normal file
45
packages/app/src/components/BodySilhouette.vue
Normal 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>
|
||||
19
packages/app/src/components/PortraitProgress.vue
Normal file
19
packages/app/src/components/PortraitProgress.vue
Normal 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>
|
||||
69
packages/app/src/components/PortraitRadar.vue
Normal file
69
packages/app/src/components/PortraitRadar.vue
Normal 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>
|
||||
@@ -42,6 +42,8 @@ const emit = defineEmits<{
|
||||
const menuItems = computed<MenuItem[]>(() => {
|
||||
const items: MenuItem[] = [
|
||||
{ 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',
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
16
packages/app/src/components/SafetyNotice.vue
Normal file
16
packages/app/src/components/SafetyNotice.vue
Normal 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>
|
||||
@@ -176,6 +176,77 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
<text class="action-btn-text action-btn-text--ghost">婉拒</text>
|
||||
</view>
|
||||
<view class="action-btn action-btn--primary" @tap.stop="handleConfirm(booking)">
|
||||
<text class="action-btn-text">确认预约</text>
|
||||
<text class="action-btn-text action-btn-text--light">确认预约</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -982,11 +982,19 @@ $cream: #efe4cf; // base cream tint
|
||||
&--primary {
|
||||
background: $sage;
|
||||
border-color: $sage;
|
||||
|
||||
.action-btn-text {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&--confirm {
|
||||
background: $ink;
|
||||
border-color: $ink;
|
||||
|
||||
.action-btn-text {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&--ghost {
|
||||
|
||||
@@ -2,170 +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" />
|
||||
<!-- Section: 课务运营 -->
|
||||
<view class="section-header">
|
||||
<text class="section-title">今日经营</text>
|
||||
</view>
|
||||
<template v-else>
|
||||
<view class="stat-block">
|
||||
<text class="stat-num">{{ stats.todayBookings }}</text>
|
||||
<text class="stat-sub">今日预约</text>
|
||||
<view class="list">
|
||||
<view class="list-item" @tap="navigate('/pages/admin/portrait-today')">
|
||||
<text class="item-title">今日经营助手</text>
|
||||
<text class="arrow-text">›</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 class="list-item" @tap="navigate('/pages/admin/portrait-leads')">
|
||||
<text class="item-title">身体画像线索</text>
|
||||
<text class="arrow-text">›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section-header"><text class="section-title">教学报告</text></view>
|
||||
<!-- Section: 课务运营 -->
|
||||
<view class="section-header">
|
||||
<text class="section-title">课务运营</text>
|
||||
</view>
|
||||
<view class="list">
|
||||
<view class="list-item" @tap="navigate('/pages/admin/schedule')">
|
||||
<text class="item-title">排课管理</text>
|
||||
<text class="arrow-text">›</text>
|
||||
</view>
|
||||
<view class="list-item" @tap="navigate('/pages/admin/bookings')">
|
||||
<text class="item-title">预约管理</text>
|
||||
<text class="arrow-text">›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Section: 教学复盘 -->
|
||||
<view class="section-header">
|
||||
<text class="section-title">教学复盘</text>
|
||||
</view>
|
||||
<view class="list">
|
||||
<view class="list-item" @tap="navigate('/pages/admin/analytics')">
|
||||
<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">月度课次 · 学员出勤 · 上课明细</text></view>
|
||||
<text class="item-title">统计分析</text>
|
||||
<text class="arrow-text">›</text>
|
||||
</view>
|
||||
<view class="item-arrow"><text class="arrow-text">›</text></view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="list"><view class="list-item" @tap="navigate('/pages/admin/reviews')"><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">学员反馈 · 星级均分 · 推荐意愿趋势</text></view></view><view class="item-arrow"><text class="arrow-text">›</text></view></view></view>
|
||||
<!-- Section header: 课程管理 -->
|
||||
<view class="section-header">
|
||||
<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>
|
||||
<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/reviews')">
|
||||
<text class="item-title">课后评价</text>
|
||||
<text class="arrow-text">›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
|
||||
<!-- 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>
|
||||
@@ -176,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
|
||||
@@ -231,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;
|
||||
}
|
||||
@@ -315,7 +154,7 @@ onMounted(() => {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* ── List ───────────────────────────────────── */
|
||||
/* ── List ───────────────────────── */
|
||||
.list {
|
||||
background: #FFFFFF;
|
||||
margin: 0 24rpx;
|
||||
@@ -328,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;
|
||||
|
||||
@@ -342,63 +181,23 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.item-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.item-icon-wrap {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 18rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.item-icon-text {
|
||||
font-size: 32rpx;
|
||||
color: #FFFFFF;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Icon variants — warm muted tones */
|
||||
.icon--bookings { background: linear-gradient(135deg, #C4A87E, #B49868); }
|
||||
.icon--schedule { background: linear-gradient(135deg, #8B9E7E, #7A8E6E); }
|
||||
.icon--template { background: linear-gradient(135deg, #A090C0, #9080B0); }
|
||||
.icon--members { background: linear-gradient(135deg, $primary-color, $primary-dark); }
|
||||
.icon--orders { background: linear-gradient(135deg, #7E9EC4, #6E8EB4); }
|
||||
.icon--card { background: linear-gradient(135deg, #C48E7E, #B47E6E); }
|
||||
.icon--studio { background: linear-gradient(135deg, #9E9E7E, #8E8E6E); }
|
||||
.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;
|
||||
|
||||
81
packages/app/src/pages/admin/portrait-assessment.vue
Normal file
81
packages/app/src/pages/admin/portrait-assessment.vue
Normal 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>
|
||||
67
packages/app/src/pages/admin/portrait-lead-detail.vue
Normal file
67
packages/app/src/pages/admin/portrait-lead-detail.vue
Normal 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>
|
||||
41
packages/app/src/pages/admin/portrait-leads.vue
Normal file
41
packages/app/src/pages/admin/portrait-leads.vue
Normal 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>
|
||||
46
packages/app/src/pages/admin/portrait-plan.vue
Normal file
46
packages/app/src/pages/admin/portrait-plan.vue
Normal 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>
|
||||
55
packages/app/src/pages/admin/portrait-today.vue
Normal file
55
packages/app/src/pages/admin/portrait-today.vue
Normal 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>
|
||||
@@ -160,6 +160,7 @@
|
||||
<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'
|
||||
@@ -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; }
|
||||
|
||||
@@ -25,6 +25,13 @@ import type {
|
||||
AdminArrangeBookingDto,
|
||||
MembershipWithCardType,
|
||||
BookingWithDetails,
|
||||
GrowthTodayDashboard,
|
||||
GrowthLeadSummary,
|
||||
GrowthLeadDetail,
|
||||
ProfessionalAssessmentSessionRecord,
|
||||
CreateProfessionalAssessmentDto,
|
||||
TrainingPlanRecord,
|
||||
CreateTrainingPlanDto,
|
||||
} from '@mp-pilates/shared'
|
||||
|
||||
interface LegacyPaginatedData<T> {
|
||||
@@ -54,12 +61,6 @@ function normalizePaginatedData<T>(
|
||||
}
|
||||
}
|
||||
|
||||
export interface AdminStats {
|
||||
todayBookings: number
|
||||
totalOrders: number
|
||||
totalBookings: number
|
||||
}
|
||||
|
||||
export type MemberSummary = AdminMemberSummary
|
||||
|
||||
export interface UserMembership {
|
||||
@@ -264,16 +265,37 @@ export const useAdminStore = defineStore('admin', () => {
|
||||
await fetchSchedulePreview(dto.date)
|
||||
}
|
||||
|
||||
// ── Dashboard stats ──────────────────────────────────────────────
|
||||
async function fetchDashboardStats(): Promise<AdminStats> {
|
||||
return get<AdminStats>('/admin/stats')
|
||||
}
|
||||
|
||||
// ── Teaching analytics ─────────────────────────────────────────
|
||||
// ── Teaching analytics ─────────────────────────────────────────
|
||||
async function fetchTeachingAnalytics(month: string): Promise<TeachingAnalytics> {
|
||||
return get<TeachingAnalytics>('/admin/teaching-analytics', { month })
|
||||
}
|
||||
|
||||
async function fetchGrowthToday() {
|
||||
return get<GrowthTodayDashboard>('/admin/growth/today')
|
||||
}
|
||||
|
||||
async function fetchGrowthLeads(params: { page?: number; search?: string; stage?: string } = {}) {
|
||||
return get<PaginatedData<GrowthLeadSummary>>('/admin/growth/leads', params)
|
||||
}
|
||||
|
||||
async function fetchGrowthLead(id: string) {
|
||||
return get<GrowthLeadDetail>(`/admin/growth/leads/${id}`)
|
||||
}
|
||||
|
||||
async function createProfessionalAssessment(userId: string, dto: CreateProfessionalAssessmentDto) {
|
||||
return post<ProfessionalAssessmentSessionRecord>(
|
||||
`/admin/members/${userId}/professional-assessments`,
|
||||
dto as unknown as Record<string, unknown>,
|
||||
)
|
||||
}
|
||||
|
||||
async function createTrainingPlan(userId: string, dto: CreateTrainingPlanDto) {
|
||||
return post<TrainingPlanRecord>(
|
||||
`/admin/members/${userId}/training-plans`,
|
||||
dto as unknown as Record<string, unknown>,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
fetchReviews, fetchReviewTrend,
|
||||
fetchTeachingAnalytics,
|
||||
@@ -315,7 +337,10 @@ export const useAdminStore = defineStore('admin', () => {
|
||||
fetchSchedulePreview,
|
||||
previewScheduleByDate,
|
||||
publishDaySlots,
|
||||
// Stats
|
||||
fetchDashboardStats,
|
||||
fetchGrowthToday,
|
||||
fetchGrowthLeads,
|
||||
fetchGrowthLead,
|
||||
createProfessionalAssessment,
|
||||
createTrainingPlan,
|
||||
}
|
||||
})
|
||||
|
||||
45
packages/app/src/pages/portrait/advice.vue
Normal file
45
packages/app/src/pages/portrait/advice.vue
Normal 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>
|
||||
156
packages/app/src/pages/portrait/assessment.vue
Normal file
156
packages/app/src/pages/portrait/assessment.vue
Normal 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>
|
||||
50
packages/app/src/pages/portrait/index.vue
Normal file
50
packages/app/src/pages/portrait/index.vue
Normal 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>
|
||||
70
packages/app/src/pages/portrait/plan.vue
Normal file
70
packages/app/src/pages/portrait/plan.vue
Normal 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>
|
||||
75
packages/app/src/pages/portrait/report.vue
Normal file
75
packages/app/src/pages/portrait/report.vue
Normal 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>
|
||||
68
packages/app/src/pages/portrait/trial.vue
Normal file
68
packages/app/src/pages/portrait/trial.vue
Normal 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>
|
||||
143
packages/app/src/stores/body-portrait.ts
Normal file
143
packages/app/src/stores/body-portrait.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
13
packages/app/src/stores/portrait-empty.ts
Normal file
13
packages/app/src/stores/portrait-empty.ts
Normal 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: [],
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useInviteStore } from './invite'
|
||||
import { useBodyPortraitStore } from './body-portrait'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type {
|
||||
@@ -161,6 +162,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
stats.value = null
|
||||
memberships.value = []
|
||||
resetSubscriptionMessageTemplateCache()
|
||||
useBodyPortraitStore().keepAnonymousOnLogout()
|
||||
}
|
||||
|
||||
function logout() {
|
||||
|
||||
@@ -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;
|
||||
@@ -84,6 +84,15 @@ model User {
|
||||
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")
|
||||
}
|
||||
@@ -207,13 +216,16 @@ 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[]
|
||||
@@ -228,6 +240,7 @@ model Booking {
|
||||
@@unique([userId, timeSlotId])
|
||||
@@index([userId])
|
||||
@@index([status])
|
||||
@@index([originAssessmentId])
|
||||
@@map("bookings")
|
||||
}
|
||||
|
||||
@@ -407,8 +420,203 @@ model ProgressPhoto {
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -4,40 +4,15 @@ 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, private readonly analytics: TeachingAnalyticsService) {}
|
||||
constructor(private readonly analytics: TeachingAnalyticsService) {}
|
||||
|
||||
@Get('teaching-analytics')
|
||||
getTeachingAnalytics(@Query('month') month: string) {
|
||||
return this.analytics.getMonthly(month)
|
||||
}
|
||||
|
||||
@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 }
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { SchedulerModule } from './scheduler/scheduler.module'
|
||||
import { PaymentModule } from './payment/payment.module'
|
||||
import { AdminModule } from './admin/admin.module'
|
||||
import { InviteModule } from './invite/invite.module'
|
||||
import { BodyPortraitModule } from './body-portrait/body-portrait.module'
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -30,6 +31,7 @@ import { InviteModule } from './invite/invite.module'
|
||||
PaymentModule,
|
||||
AdminModule,
|
||||
InviteModule,
|
||||
BodyPortraitModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
})
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,6 +68,9 @@ 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 {
|
||||
@@ -179,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('周前肩颈紧张')
|
||||
})
|
||||
})
|
||||
@@ -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('李同学你好,前两天的普拉提体验课感觉怎么样?')
|
||||
})
|
||||
})
|
||||
85
packages/server/src/body-portrait/__tests__/score-v1.spec.ts
Normal file
85
packages/server/src/body-portrait/__tests__/score-v1.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
271
packages/server/src/body-portrait/body-portrait-admin.service.ts
Normal file
271
packages/server/src/body-portrait/body-portrait-admin.service.ts
Normal 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: [],
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
109
packages/server/src/body-portrait/body-portrait.controller.ts
Normal file
109
packages/server/src/body-portrait/body-portrait.controller.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { Body, Controller, Get, Param, Post, Put, Req, UseGuards } from '@nestjs/common'
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator'
|
||||
import { BodyPortraitService } from './body-portrait.service'
|
||||
import { BodyPortraitOfflineService } from './body-portrait-offline.service'
|
||||
import {
|
||||
AccessTokenDto,
|
||||
CreateAssessmentDto,
|
||||
CreateShareCardDto,
|
||||
CreateVisitDto,
|
||||
SaveAnswersDto,
|
||||
TrackEventDto,
|
||||
} from './dto/body-portrait.dto'
|
||||
|
||||
interface RequestLike {
|
||||
ip?: string
|
||||
headers: Record<string, string | string[] | undefined>
|
||||
}
|
||||
|
||||
function clientIp(req: RequestLike): string {
|
||||
const forwarded = req.headers['x-forwarded-for']
|
||||
const raw = Array.isArray(forwarded) ? forwarded[0] : forwarded
|
||||
return (raw?.split(',')[0] || req.ip || 'unknown').trim()
|
||||
}
|
||||
|
||||
@Controller('body-portrait')
|
||||
export class BodyPortraitController {
|
||||
constructor(
|
||||
private readonly portraits: BodyPortraitService,
|
||||
private readonly offline: BodyPortraitOfflineService,
|
||||
) {}
|
||||
|
||||
@Get('stats')
|
||||
stats() {
|
||||
return this.portraits.stats()
|
||||
}
|
||||
|
||||
@Post('visits')
|
||||
createVisit(@Req() req: RequestLike, @Body() dto: CreateVisitDto) {
|
||||
return this.portraits.createVisit(clientIp(req), dto)
|
||||
}
|
||||
|
||||
@Post('assessments')
|
||||
start(@Req() req: RequestLike, @Body() dto: CreateAssessmentDto) {
|
||||
return this.portraits.startAssessment(clientIp(req), dto.visitToken)
|
||||
}
|
||||
|
||||
@Put('assessments')
|
||||
save(@Body() dto: SaveAnswersDto) {
|
||||
return this.portraits.saveAnswers(dto.accessToken, dto.answers)
|
||||
}
|
||||
|
||||
@Post('assessments/complete')
|
||||
complete(@Body() dto: AccessTokenDto) {
|
||||
return this.portraits.complete(dto.accessToken)
|
||||
}
|
||||
|
||||
@Post('assessments/claim')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
claim(@CurrentUser('sub') userId: string, @Body() dto: AccessTokenDto) {
|
||||
return this.portraits.claim(userId, dto.accessToken)
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
me(@CurrentUser('sub') userId: string) {
|
||||
return this.portraits.myLatest(userId)
|
||||
}
|
||||
|
||||
@Get('assessments/:id')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
one(@CurrentUser('sub') userId: string, @Param('id') id: string) {
|
||||
return this.portraits.myReport(userId, id)
|
||||
}
|
||||
|
||||
@Post('events')
|
||||
track(@Body() dto: TrackEventDto) {
|
||||
return this.portraits.track(undefined, dto)
|
||||
}
|
||||
|
||||
@Get('sessions')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
sessions(@CurrentUser('sub') userId: string) {
|
||||
return this.offline.listSessions(userId)
|
||||
}
|
||||
|
||||
@Get('plans')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
plans(@CurrentUser('sub') userId: string) {
|
||||
return this.offline.listPlans(userId)
|
||||
}
|
||||
|
||||
@Get('todos')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
todos(@CurrentUser('sub') userId: string) {
|
||||
return this.offline.todos(userId)
|
||||
}
|
||||
|
||||
@Post('share-cards')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
shareCard(@CurrentUser('sub') userId: string, @Body() dto: CreateShareCardDto) {
|
||||
return this.offline.createShareCard(userId, dto.includePhotos === true)
|
||||
}
|
||||
|
||||
@Get('share/:code')
|
||||
publicShare(@Param('code') code: string) {
|
||||
return this.offline.publicShare(code)
|
||||
}
|
||||
}
|
||||
23
packages/server/src/body-portrait/body-portrait.module.ts
Normal file
23
packages/server/src/body-portrait/body-portrait.module.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Module, forwardRef } from '@nestjs/common'
|
||||
import { AuthModule } from '../auth/auth.module'
|
||||
import { BodyPortraitAdminController } from './body-portrait-admin.controller'
|
||||
import { BodyPortraitAdminService } from './body-portrait-admin.service'
|
||||
import { BodyPortraitController } from './body-portrait.controller'
|
||||
import { BodyPortraitLifecycleService } from './body-portrait-lifecycle.service'
|
||||
import { BodyPortraitOfflineService } from './body-portrait-offline.service'
|
||||
import { BodyPortraitScheduler } from './body-portrait.scheduler'
|
||||
import { BodyPortraitService } from './body-portrait.service'
|
||||
|
||||
@Module({
|
||||
imports: [forwardRef(() => AuthModule)],
|
||||
controllers: [BodyPortraitController, BodyPortraitAdminController],
|
||||
providers: [
|
||||
BodyPortraitLifecycleService,
|
||||
BodyPortraitService,
|
||||
BodyPortraitAdminService,
|
||||
BodyPortraitOfflineService,
|
||||
BodyPortraitScheduler,
|
||||
],
|
||||
exports: [BodyPortraitLifecycleService, BodyPortraitService],
|
||||
})
|
||||
export class BodyPortraitModule {}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { HttpException, HttpStatus } from '@nestjs/common'
|
||||
|
||||
interface Bucket {
|
||||
count: number
|
||||
resetAt: number
|
||||
}
|
||||
|
||||
const buckets = new Map<string, Bucket>()
|
||||
let lastCleanAt = 0
|
||||
|
||||
function pruneBuckets(now: number) {
|
||||
if (now - lastCleanAt < 60_000 && buckets.size < 1000) return
|
||||
lastCleanAt = now
|
||||
for (const [key, bucket] of buckets.entries()) {
|
||||
if (bucket.resetAt <= now) {
|
||||
buckets.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function assertRateLimit(key: string, limit: number, windowMs = 60 * 60 * 1000) {
|
||||
const now = Date.now()
|
||||
pruneBuckets(now)
|
||||
const current = buckets.get(key)
|
||||
if (!current || current.resetAt <= now) {
|
||||
buckets.set(key, { count: 1, resetAt: now + windowMs })
|
||||
return
|
||||
}
|
||||
if (current.count >= limit) {
|
||||
throw new HttpException('请求过于频繁,请稍后再试', HttpStatus.TOO_MANY_REQUESTS)
|
||||
}
|
||||
current.count += 1
|
||||
}
|
||||
16
packages/server/src/body-portrait/body-portrait.scheduler.ts
Normal file
16
packages/server/src/body-portrait/body-portrait.scheduler.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Injectable, Logger } from '@nestjs/common'
|
||||
import { Cron } from '@nestjs/schedule'
|
||||
import { BodyPortraitService } from './body-portrait.service'
|
||||
|
||||
@Injectable()
|
||||
export class BodyPortraitScheduler {
|
||||
private readonly logger = new Logger(BodyPortraitScheduler.name)
|
||||
|
||||
constructor(private readonly portraits: BodyPortraitService) {}
|
||||
|
||||
@Cron('15 3 * * *')
|
||||
async expireDrafts() {
|
||||
const result = await this.portraits.expireDrafts()
|
||||
if (result.expired) this.logger.log(`expired ${result.expired} anonymous portrait drafts`)
|
||||
}
|
||||
}
|
||||
300
packages/server/src/body-portrait/body-portrait.service.ts
Normal file
300
packages/server/src/body-portrait/body-portrait.service.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common'
|
||||
import {
|
||||
BODY_PORTRAIT_QUESTIONNAIRE_VERSION,
|
||||
BODY_PORTRAIT_RULES_VERSION,
|
||||
BodyPortraitAnswers,
|
||||
BodyPortraitAssessmentStatus,
|
||||
BodyPortraitReport,
|
||||
BodyPortraitSessionResponse,
|
||||
GrowthEventName,
|
||||
GrowthLeadStage,
|
||||
} from '@mp-pilates/shared'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { PrismaService } from '../prisma/prisma.service'
|
||||
import { BodyPortraitLifecycleService } from './body-portrait-lifecycle.service'
|
||||
import { assertRateLimit } from './body-portrait.rate-limit'
|
||||
import { createAccessToken, hashToken } from './body-portrait.token'
|
||||
import { EMPTY_ANSWERS, normalizeAnswers, scorePortrait, toTeaser } from './scoring/score-v1'
|
||||
|
||||
const DRAFT_TTL_MS = 30 * 24 * 60 * 60 * 1000
|
||||
const ANSWER_LIMIT = 8000
|
||||
|
||||
@Injectable()
|
||||
export class BodyPortraitService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly lifecycle: BodyPortraitLifecycleService,
|
||||
) {}
|
||||
|
||||
async stats() {
|
||||
const completedCount = await this.prisma.bodyPortraitAssessment.count({
|
||||
where: { status: { in: [BodyPortraitAssessmentStatus.COMPLETED, BodyPortraitAssessmentStatus.CLAIMED] } },
|
||||
})
|
||||
return { completedCount }
|
||||
}
|
||||
|
||||
async createVisit(ip: string, dto: {
|
||||
source?: string
|
||||
campaignId?: string
|
||||
referralCode?: string
|
||||
landingPath?: string
|
||||
}) {
|
||||
assertRateLimit(`visit:${ip}`, 30)
|
||||
const visitToken = createAccessToken()
|
||||
const source = this.lifecycle.parseSource(dto.source)
|
||||
const referralCode = dto.referralCode?.trim().toUpperCase() || null
|
||||
const referrer = referralCode
|
||||
? await this.prisma.user.findFirst({ where: { inviteCode: referralCode }, select: { id: true } })
|
||||
: null
|
||||
const visit = await this.prisma.bodyPortraitVisit.create({
|
||||
data: {
|
||||
tokenHash: hashToken(visitToken),
|
||||
source,
|
||||
campaignId: dto.campaignId?.slice(0, 40) || null,
|
||||
referralCode,
|
||||
referrerUserId: referrer?.id,
|
||||
landingPath: (dto.landingPath || '/pages/portrait/index').slice(0, 120),
|
||||
},
|
||||
})
|
||||
return { visitId: visit.id, visitToken, source }
|
||||
}
|
||||
|
||||
async startAssessment(ip: string, visitToken: string): Promise<BodyPortraitSessionResponse> {
|
||||
assertRateLimit(`start:${ip}`, 40)
|
||||
const visit = await this.visitByToken(visitToken)
|
||||
const existing = await this.prisma.bodyPortraitAssessment.findFirst({
|
||||
where: {
|
||||
visitId: visit.id,
|
||||
status: { in: [BodyPortraitAssessmentStatus.DRAFT, BodyPortraitAssessmentStatus.COMPLETED] },
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
if (existing && !existing.userId) {
|
||||
const accessToken = createAccessToken()
|
||||
const rotated = await this.prisma.bodyPortraitAssessment.update({
|
||||
where: { id: existing.id },
|
||||
data: { tokenHash: hashToken(accessToken) },
|
||||
})
|
||||
return this.sessionFrom(rotated, accessToken, visitToken, false)
|
||||
}
|
||||
const accessToken = createAccessToken()
|
||||
const assessment = await this.prisma.bodyPortraitAssessment.create({
|
||||
data: {
|
||||
visitId: visit.id,
|
||||
tokenHash: hashToken(accessToken),
|
||||
questionnaireVersion: BODY_PORTRAIT_QUESTIONNAIRE_VERSION,
|
||||
rulesVersion: BODY_PORTRAIT_RULES_VERSION,
|
||||
status: BodyPortraitAssessmentStatus.DRAFT,
|
||||
answers: EMPTY_ANSWERS as unknown as Prisma.InputJsonValue,
|
||||
expiresAt: new Date(Date.now() + DRAFT_TTL_MS),
|
||||
},
|
||||
})
|
||||
await this.lifecycle.recordEvent({
|
||||
name: GrowthEventName.ASSESSMENT_STARTED,
|
||||
idempotencyKey: `assessment_started:${assessment.id}`,
|
||||
visitId: visit.id,
|
||||
assessmentId: assessment.id,
|
||||
stage: GrowthLeadStage.STARTED,
|
||||
})
|
||||
await this.prisma.bodyPortraitVisit.update({
|
||||
where: { id: visit.id },
|
||||
data: { lastSeenAt: new Date() },
|
||||
})
|
||||
return this.sessionFrom(assessment, accessToken, visitToken, false)
|
||||
}
|
||||
|
||||
async saveAnswers(accessToken: string, raw: unknown): Promise<BodyPortraitSessionResponse> {
|
||||
if (JSON.stringify(raw || {}).length > ANSWER_LIMIT) {
|
||||
throw new BadRequestException('答卷内容过长')
|
||||
}
|
||||
const assessment = await this.assessmentByToken(accessToken)
|
||||
if (assessment.status === BodyPortraitAssessmentStatus.CLAIMED) {
|
||||
throw new ForbiddenException('报告已认领,不能再修改')
|
||||
}
|
||||
if (assessment.status === BodyPortraitAssessmentStatus.EXPIRED || assessment.expiresAt < new Date()) {
|
||||
throw new BadRequestException('测评已过期,请重新开始')
|
||||
}
|
||||
const answers = normalizeAnswers(raw as Partial<BodyPortraitAnswers>)
|
||||
const updated = await this.prisma.bodyPortraitAssessment.update({
|
||||
where: { id: assessment.id },
|
||||
data: { answers: answers as unknown as Prisma.InputJsonValue },
|
||||
})
|
||||
return this.sessionFrom(updated, accessToken, '', false)
|
||||
}
|
||||
|
||||
async complete(accessToken: string): Promise<BodyPortraitSessionResponse> {
|
||||
const assessment = await this.assessmentByToken(accessToken)
|
||||
if (assessment.status === BodyPortraitAssessmentStatus.EXPIRED || assessment.expiresAt < new Date()) {
|
||||
throw new BadRequestException('测评已过期,请重新开始')
|
||||
}
|
||||
const answers = normalizeAnswers(assessment.answers as Partial<BodyPortraitAnswers>)
|
||||
if (!answers.goal || !answers.sittingHours || !answers.exerciseFreq || !answers.workPosture || !answers.safety.length) {
|
||||
throw new BadRequestException('请完成必答题后再生成画像')
|
||||
}
|
||||
const report = scorePortrait(answers)
|
||||
const updated = await this.prisma.bodyPortraitAssessment.update({
|
||||
where: { id: assessment.id },
|
||||
data: {
|
||||
answers: answers as unknown as Prisma.InputJsonValue,
|
||||
report: report as unknown as Prisma.InputJsonValue,
|
||||
safetyFlagged: report.safety.flagged,
|
||||
status: assessment.status === BodyPortraitAssessmentStatus.CLAIMED
|
||||
? BodyPortraitAssessmentStatus.CLAIMED
|
||||
: BodyPortraitAssessmentStatus.COMPLETED,
|
||||
completedAt: assessment.completedAt || new Date(),
|
||||
rulesVersion: BODY_PORTRAIT_RULES_VERSION,
|
||||
},
|
||||
})
|
||||
await this.lifecycle.recordEvent({
|
||||
name: GrowthEventName.ASSESSMENT_COMPLETED,
|
||||
idempotencyKey: `assessment_completed:${assessment.id}`,
|
||||
visitId: assessment.visitId,
|
||||
assessmentId: assessment.id,
|
||||
userId: assessment.userId,
|
||||
stage: GrowthLeadStage.COMPLETED,
|
||||
})
|
||||
return this.sessionFrom(updated, accessToken, '', false)
|
||||
}
|
||||
|
||||
async claim(userId: string, accessToken: string): Promise<BodyPortraitSessionResponse> {
|
||||
const assessment = await this.assessmentByToken(accessToken)
|
||||
if (assessment.userId && assessment.userId !== userId) {
|
||||
throw new ForbiddenException('这份测评已经属于其他用户')
|
||||
}
|
||||
if (assessment.status === BodyPortraitAssessmentStatus.DRAFT || !assessment.report) {
|
||||
throw new BadRequestException('请先完成测评')
|
||||
}
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
const claimed = await tx.bodyPortraitAssessment.updateMany({
|
||||
where: {
|
||||
id: assessment.id,
|
||||
OR: [{ userId: null }, { userId }],
|
||||
},
|
||||
data: {
|
||||
userId,
|
||||
status: BodyPortraitAssessmentStatus.CLAIMED,
|
||||
claimedAt: assessment.claimedAt || new Date(),
|
||||
},
|
||||
})
|
||||
if (!claimed.count) throw new ForbiddenException('认领失败')
|
||||
await tx.bodyPortraitVisit.update({
|
||||
where: { id: assessment.visitId },
|
||||
data: { userId, lastSeenAt: new Date() },
|
||||
})
|
||||
return tx.bodyPortraitAssessment.findUniqueOrThrow({ where: { id: assessment.id } })
|
||||
})
|
||||
await this.lifecycle.recordEvent({
|
||||
name: GrowthEventName.ASSESSMENT_CLAIMED,
|
||||
idempotencyKey: `assessment_claimed:${assessment.id}:${userId}`,
|
||||
visitId: assessment.visitId,
|
||||
assessmentId: assessment.id,
|
||||
userId,
|
||||
stage: GrowthLeadStage.CLAIMED,
|
||||
})
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { phone: true } })
|
||||
if (user?.phone) await this.lifecycle.onPhoneBound(userId)
|
||||
return this.sessionFrom(updated, accessToken, '', true)
|
||||
}
|
||||
|
||||
async myLatest(userId: string): Promise<BodyPortraitSessionResponse | null> {
|
||||
const assessment = await this.prisma.bodyPortraitAssessment.findFirst({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
if (!assessment) return null
|
||||
return this.sessionFrom(assessment, '', '', true)
|
||||
}
|
||||
|
||||
async myReport(userId: string, assessmentId: string): Promise<BodyPortraitSessionResponse> {
|
||||
const assessment = await this.prisma.bodyPortraitAssessment.findFirst({
|
||||
where: { id: assessmentId, userId },
|
||||
})
|
||||
if (!assessment) throw new NotFoundException('报告不存在')
|
||||
return this.sessionFrom(assessment, '', '', true)
|
||||
}
|
||||
|
||||
async track(userId: string | undefined, dto: { name: GrowthEventName; accessToken?: string; idempotencyKey?: string }) {
|
||||
const assessment = dto.accessToken ? await this.assessmentByToken(dto.accessToken) : null
|
||||
if (assessment && userId && assessment.userId && assessment.userId !== userId) {
|
||||
throw new ForbiddenException('无权记录该测评事件')
|
||||
}
|
||||
const owner = userId || assessment?.userId
|
||||
await this.lifecycle.recordEvent({
|
||||
name: dto.name,
|
||||
idempotencyKey: dto.idempotencyKey || `${dto.name}:${assessment?.id || owner || 'anon'}`,
|
||||
visitId: assessment?.visitId,
|
||||
assessmentId: assessment?.id,
|
||||
userId: owner,
|
||||
})
|
||||
return { recorded: true }
|
||||
}
|
||||
|
||||
async assertOwnedAssessment(userId: string, assessmentId: string) {
|
||||
const assessment = await this.prisma.bodyPortraitAssessment.findFirst({
|
||||
where: { id: assessmentId, userId },
|
||||
})
|
||||
if (!assessment) throw new ForbiddenException('画像报告不属于当前用户')
|
||||
return assessment
|
||||
}
|
||||
|
||||
async expireDrafts(now = new Date()) {
|
||||
const result = await this.prisma.bodyPortraitAssessment.updateMany({
|
||||
where: {
|
||||
status: BodyPortraitAssessmentStatus.DRAFT,
|
||||
expiresAt: { lte: now },
|
||||
},
|
||||
data: { status: BodyPortraitAssessmentStatus.EXPIRED },
|
||||
})
|
||||
return { expired: result.count }
|
||||
}
|
||||
|
||||
private async visitByToken(visitToken: string) {
|
||||
const visit = await this.prisma.bodyPortraitVisit.findUnique({
|
||||
where: { tokenHash: hashToken(visitToken) },
|
||||
})
|
||||
if (!visit) throw new NotFoundException('访问凭证无效')
|
||||
return visit
|
||||
}
|
||||
|
||||
private async assessmentByToken(accessToken: string) {
|
||||
const assessment = await this.prisma.bodyPortraitAssessment.findUnique({
|
||||
where: { tokenHash: hashToken(accessToken) },
|
||||
})
|
||||
if (!assessment) throw new NotFoundException('测评凭证无效')
|
||||
return assessment
|
||||
}
|
||||
|
||||
private sessionFrom(
|
||||
assessment: {
|
||||
id: string
|
||||
status: string
|
||||
answers: Prisma.JsonValue
|
||||
report: Prisma.JsonValue | null
|
||||
userId: string | null
|
||||
},
|
||||
accessToken: string,
|
||||
visitToken: string,
|
||||
claimedView: boolean,
|
||||
): BodyPortraitSessionResponse {
|
||||
const answers = normalizeAnswers(assessment.answers as Partial<BodyPortraitAnswers>)
|
||||
const report = (assessment.report || null) as BodyPortraitReport | null
|
||||
const claimed = assessment.status === BodyPortraitAssessmentStatus.CLAIMED && claimedView
|
||||
return {
|
||||
assessmentId: assessment.id,
|
||||
accessToken,
|
||||
visitToken,
|
||||
questionnaireVersion: BODY_PORTRAIT_QUESTIONNAIRE_VERSION,
|
||||
status: assessment.status as BodyPortraitAssessmentStatus,
|
||||
answers,
|
||||
teaser: report ? toTeaser(assessment.id, report) : null,
|
||||
report: claimedView ? report : null,
|
||||
claimed,
|
||||
}
|
||||
}
|
||||
}
|
||||
13
packages/server/src/body-portrait/body-portrait.token.ts
Normal file
13
packages/server/src/body-portrait/body-portrait.token.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createHash, randomBytes } from 'crypto'
|
||||
|
||||
export function createAccessToken(): string {
|
||||
return randomBytes(32).toString('hex')
|
||||
}
|
||||
|
||||
export function hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex')
|
||||
}
|
||||
|
||||
export function createShareCode(): string {
|
||||
return randomBytes(6).toString('hex')
|
||||
}
|
||||
179
packages/server/src/body-portrait/dto/body-portrait.dto.ts
Normal file
179
packages/server/src/body-portrait/dto/body-portrait.dto.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { Type } from 'class-transformer'
|
||||
import {
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsEnum,
|
||||
IsIn,
|
||||
IsNumber,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Matches,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator'
|
||||
import {
|
||||
BodyPortraitSource,
|
||||
GrowthEventName,
|
||||
ProfessionalAssessmentKind,
|
||||
ProgressPhotoAngle,
|
||||
} from '@mp-pilates/shared'
|
||||
|
||||
const CLIENT_EVENTS = [
|
||||
GrowthEventName.REPORT_VIEWED,
|
||||
GrowthEventName.ADVICE_VIEWED,
|
||||
GrowthEventName.TRIAL_CLICKED,
|
||||
GrowthEventName.REPORT_SHARED,
|
||||
]
|
||||
|
||||
export class CreateVisitDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
source?: string
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
campaignId?: string
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(12)
|
||||
referralCode?: string
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
landingPath?: string
|
||||
}
|
||||
|
||||
export class CreateAssessmentDto {
|
||||
@IsString()
|
||||
@MinLength(16)
|
||||
@MaxLength(128)
|
||||
visitToken!: string
|
||||
}
|
||||
|
||||
export class SaveAnswersDto {
|
||||
@IsString()
|
||||
@MinLength(16)
|
||||
@MaxLength(128)
|
||||
accessToken!: string
|
||||
|
||||
@IsObject()
|
||||
answers!: Record<string, unknown>
|
||||
}
|
||||
|
||||
export class AccessTokenDto {
|
||||
@IsString()
|
||||
@MinLength(16)
|
||||
@MaxLength(128)
|
||||
accessToken!: string
|
||||
}
|
||||
|
||||
export class TrackEventDto {
|
||||
@IsIn(CLIENT_EVENTS)
|
||||
name!: GrowthEventName
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(16)
|
||||
@MaxLength(128)
|
||||
accessToken?: string
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
idempotencyKey?: string
|
||||
}
|
||||
|
||||
export class UpdateLeadNoteDto {
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
coachNote!: string
|
||||
}
|
||||
|
||||
class ObservationDto {
|
||||
@IsNumber() @Min(1) @Max(5) headPosition!: number
|
||||
@IsNumber() @Min(1) @Max(5) shoulderPosition!: number
|
||||
@IsNumber() @Min(1) @Max(5) thoracicExtension!: number
|
||||
@IsNumber() @Min(1) @Max(5) shoulderFlexion!: number
|
||||
@IsNumber() @Min(1) @Max(5) breathing!: number
|
||||
@IsNumber() @Min(1) @Max(5) pelvis!: number
|
||||
@IsNumber() @Min(1) @Max(5) coreControl!: number
|
||||
@IsNumber() @Min(1) @Max(5) hipMobility!: number
|
||||
@IsNumber() @Min(1) @Max(5) singleLeg!: number
|
||||
}
|
||||
|
||||
export class CreateProfessionalAssessmentDto {
|
||||
@IsEnum(ProfessionalAssessmentKind)
|
||||
kind!: ProfessionalAssessmentKind
|
||||
|
||||
@IsDateString()
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
recordedAt!: string
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
bookingId?: string
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
originAssessmentId?: string
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => ObservationDto)
|
||||
observations!: ObservationDto
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(10)
|
||||
subjectiveTension?: number | null
|
||||
|
||||
@IsString()
|
||||
@MaxLength(1000)
|
||||
coachSummary!: string
|
||||
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
trainingFocus!: string
|
||||
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
phaseGoal!: string
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ProgressPhotoAngle)
|
||||
photoAngle?: ProgressPhotoAngle | null
|
||||
}
|
||||
|
||||
export class CreateTrainingPlanDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
sessionId?: string
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
title?: string
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(4)
|
||||
@Max(24)
|
||||
weeks?: number
|
||||
}
|
||||
|
||||
export class CreateShareCardDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
includePhotos?: boolean
|
||||
}
|
||||
|
||||
export { BodyPortraitSource }
|
||||
548
packages/server/src/body-portrait/scoring/score-v1.ts
Normal file
548
packages/server/src/body-portrait/scoring/score-v1.ts
Normal file
@@ -0,0 +1,548 @@
|
||||
import {
|
||||
AfterSitting,
|
||||
BODY_DIMENSION_LABELS,
|
||||
BODY_PORTRAIT_DISCLAIMER,
|
||||
BODY_PORTRAIT_QUESTIONNAIRE_VERSION,
|
||||
BODY_PORTRAIT_RULES_VERSION,
|
||||
BODY_REGION_LABELS,
|
||||
BodyDimension,
|
||||
BodyPortraitAdvice,
|
||||
BodyPortraitAnswers,
|
||||
BodyPortraitChain,
|
||||
BodyPortraitEvidence,
|
||||
BodyPortraitLevels,
|
||||
BodyPortraitReport,
|
||||
BodyPortraitSafetyResult,
|
||||
BodyPortraitScores,
|
||||
BodyRegion,
|
||||
ConcernLevel,
|
||||
ExerciseFreq,
|
||||
PortraitGoal,
|
||||
PORTRAIT_GOAL_LABELS,
|
||||
PortraitMatchQuality,
|
||||
PortraitType,
|
||||
PORTRAIT_TYPE_LABELS,
|
||||
SafetyFlag,
|
||||
SittingHours,
|
||||
StandingNotice,
|
||||
WorkPosture,
|
||||
AFTER_SITTING_LABELS,
|
||||
EXERCISE_FREQ_LABELS,
|
||||
SITTING_HOURS_LABELS,
|
||||
STANDING_NOTICE_LABELS,
|
||||
WORK_POSTURE_LABELS,
|
||||
} from '@mp-pilates/shared'
|
||||
|
||||
export const EMPTY_ANSWERS: BodyPortraitAnswers = {
|
||||
concerns: [],
|
||||
goal: null,
|
||||
sittingHours: null,
|
||||
exerciseFreq: null,
|
||||
workPosture: null,
|
||||
endOfDayFatigue: [],
|
||||
afterSitting: [],
|
||||
standingNotice: [],
|
||||
safety: [],
|
||||
}
|
||||
|
||||
type MutableScores = {
|
||||
-readonly [K in keyof BodyPortraitScores]: number
|
||||
}
|
||||
|
||||
interface EvidenceCandidate {
|
||||
readonly dimension: BodyDimension
|
||||
readonly title: string
|
||||
readonly answers: readonly string[]
|
||||
readonly explanation: string
|
||||
readonly weight: number
|
||||
}
|
||||
|
||||
const DIMENSIONS: readonly BodyDimension[] = [
|
||||
BodyDimension.CERVICAL_SHOULDER,
|
||||
BodyDimension.SPINAL_MOBILITY,
|
||||
BodyDimension.CORE_CONTROL,
|
||||
BodyDimension.HIP_PELVIS,
|
||||
BodyDimension.LOWER_LIMB,
|
||||
]
|
||||
|
||||
const REGION_VALUES = new Set(Object.values(BodyRegion))
|
||||
const GOAL_VALUES = new Set(Object.values(PortraitGoal))
|
||||
const SITTING_VALUES = new Set(Object.values(SittingHours))
|
||||
const EXERCISE_VALUES = new Set(Object.values(ExerciseFreq))
|
||||
const WORK_VALUES = new Set(Object.values(WorkPosture))
|
||||
const AFTER_VALUES = new Set(Object.values(AfterSitting))
|
||||
const STANDING_VALUES = new Set(Object.values(StandingNotice))
|
||||
const SAFETY_VALUES = new Set(Object.values(SafetyFlag))
|
||||
|
||||
function emptyScores(): MutableScores {
|
||||
return {
|
||||
cervicalShoulder: 0,
|
||||
spinalMobility: 0,
|
||||
coreControl: 0,
|
||||
hipPelvis: 0,
|
||||
lowerLimb: 0,
|
||||
}
|
||||
}
|
||||
|
||||
function add(scores: MutableScores, dimension: BodyDimension, amount: number) {
|
||||
scores[dimension] += amount
|
||||
}
|
||||
|
||||
function clamp(value: number): number {
|
||||
return Math.max(0, Math.min(100, value))
|
||||
}
|
||||
|
||||
export function concernLevel(score: number): ConcernLevel {
|
||||
if (score >= 86) return ConcernLevel.PRIORITY
|
||||
if (score >= 71) return ConcernLevel.HIGH
|
||||
if (score >= 51) return ConcernLevel.MODERATE
|
||||
if (score >= 31) return ConcernLevel.MILD
|
||||
return ConcernLevel.LOW
|
||||
}
|
||||
|
||||
export function safetyFlagsOf(answers: BodyPortraitAnswers): SafetyFlag[] {
|
||||
return answers.safety.filter((flag) => flag !== SafetyFlag.NONE)
|
||||
}
|
||||
|
||||
export function isSafetyFlagged(answers: BodyPortraitAnswers): boolean {
|
||||
return safetyFlagsOf(answers).length > 0
|
||||
}
|
||||
|
||||
function list<T>(values: readonly T[], labels: Record<string, string>): string[] {
|
||||
return values.map((value) => labels[String(value)] || String(value))
|
||||
}
|
||||
|
||||
function applyRegions(scores: MutableScores, regions: readonly BodyRegion[], weight: number, evidence: EvidenceCandidate[], title: string, explanation: string) {
|
||||
for (const region of regions) {
|
||||
if (region === BodyRegion.NECK || region === BodyRegion.SHOULDER) {
|
||||
add(scores, BodyDimension.CERVICAL_SHOULDER, weight)
|
||||
} else if (region === BodyRegion.UPPER_BACK) {
|
||||
add(scores, BodyDimension.SPINAL_MOBILITY, weight)
|
||||
} else if (region === BodyRegion.LOW_BACK) {
|
||||
add(scores, BodyDimension.SPINAL_MOBILITY, Math.round(weight * 0.6))
|
||||
add(scores, BodyDimension.CORE_CONTROL, weight)
|
||||
} else if (region === BodyRegion.PELVIS || region === BodyRegion.HIP) {
|
||||
add(scores, BodyDimension.HIP_PELVIS, weight)
|
||||
} else {
|
||||
add(scores, BodyDimension.LOWER_LIMB, weight)
|
||||
}
|
||||
}
|
||||
if (regions.length) {
|
||||
const top = regions[0] === BodyRegion.NECK || regions[0] === BodyRegion.SHOULDER
|
||||
? BodyDimension.CERVICAL_SHOULDER
|
||||
: regions[0] === BodyRegion.UPPER_BACK || regions[0] === BodyRegion.LOW_BACK
|
||||
? regions[0] === BodyRegion.LOW_BACK ? BodyDimension.CORE_CONTROL : BodyDimension.SPINAL_MOBILITY
|
||||
: regions[0] === BodyRegion.PELVIS || regions[0] === BodyRegion.HIP
|
||||
? BodyDimension.HIP_PELVIS
|
||||
: BodyDimension.LOWER_LIMB
|
||||
evidence.push({
|
||||
dimension: top,
|
||||
title,
|
||||
answers: list(regions, BODY_REGION_LABELS),
|
||||
explanation,
|
||||
weight: weight * regions.length,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function pickType(scores: BodyPortraitScores, answers: BodyPortraitAnswers): { primary: PortraitType; secondary: PortraitType | null } {
|
||||
const entries = DIMENSIONS.map((dimension) => [dimension, scores[dimension]] as const)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
const max = entries[0][1]
|
||||
const highCount = entries.filter(([, score]) => score >= 50).length
|
||||
const sedentary = answers.sittingHours === SittingHours.GT8 || answers.sittingHours === SittingHours.H6_TO_8
|
||||
const deskLike = answers.workPosture === WorkPosture.COMPUTER || answers.workPosture === WorkPosture.PHONE
|
||||
|
||||
if (max < 35) return { primary: PortraitType.LOW_OVERALL_CONCERN, secondary: null }
|
||||
|
||||
let primary: PortraitType
|
||||
if (scores.cervicalShoulder >= 60 && scores.coreControl >= 45 && scores.hipPelvis >= 40) {
|
||||
primary = PortraitType.UPPER_LOWER_CROSS
|
||||
} else if (highCount >= 3) {
|
||||
primary = PortraitType.MIXED_FATIGUE
|
||||
} else if (entries[0][0] === BodyDimension.CERVICAL_SHOULDER && scores.cervicalShoulder >= 55 && (sedentary || deskLike)) {
|
||||
primary = PortraitType.SEDENTARY_NECK_TENSION
|
||||
} else if (entries[0][0] === BodyDimension.CORE_CONTROL && sedentary) {
|
||||
primary = PortraitType.SEDENTARY_CORE_WEAK
|
||||
} else if (entries[0][0] === BodyDimension.CERVICAL_SHOULDER) {
|
||||
primary = PortraitType.NECK_COMPENSATION
|
||||
} else if (entries[0][0] === BodyDimension.HIP_PELVIS) {
|
||||
primary = PortraitType.HIP_TIGHT
|
||||
} else if (entries[0][0] === BodyDimension.LOWER_LIMB) {
|
||||
primary = PortraitType.LOWER_LIMB_UNSTABLE
|
||||
} else if (entries[0][0] === BodyDimension.SPINAL_MOBILITY || entries[0][0] === BodyDimension.CORE_CONTROL) {
|
||||
primary = sedentary ? PortraitType.SEDENTARY_CORE_WEAK : PortraitType.MIXED_FATIGUE
|
||||
} else {
|
||||
primary = PortraitType.MIXED_FATIGUE
|
||||
}
|
||||
|
||||
let secondary: PortraitType | null = null
|
||||
if (entries[1][1] >= 55) {
|
||||
const mapped = mapDimensionType(entries[1][0], sedentary)
|
||||
if (mapped !== primary) secondary = mapped
|
||||
}
|
||||
return { primary, secondary }
|
||||
}
|
||||
|
||||
function mapDimensionType(dimension: BodyDimension, sedentary: boolean): PortraitType {
|
||||
if (dimension === BodyDimension.CERVICAL_SHOULDER) {
|
||||
return sedentary ? PortraitType.SEDENTARY_NECK_TENSION : PortraitType.NECK_COMPENSATION
|
||||
}
|
||||
if (dimension === BodyDimension.CORE_CONTROL || dimension === BodyDimension.SPINAL_MOBILITY) {
|
||||
return sedentary ? PortraitType.SEDENTARY_CORE_WEAK : PortraitType.MIXED_FATIGUE
|
||||
}
|
||||
if (dimension === BodyDimension.HIP_PELVIS) return PortraitType.HIP_TIGHT
|
||||
return PortraitType.LOWER_LIMB_UNSTABLE
|
||||
}
|
||||
|
||||
function adviceFor(type: PortraitType, safety: boolean): BodyPortraitAdvice | null {
|
||||
if (safety) return null
|
||||
if (type === PortraitType.HIP_TIGHT || type === PortraitType.LOWER_LIMB_UNSTABLE) {
|
||||
return {
|
||||
id: 'hip_pack',
|
||||
items: [
|
||||
{ title: '每坐一段时间做一次髋部开合或体重转移', detail: '让髋部重新参与日常活动,而不是长时间保持同一个坐姿。', stopIf: '出现明显刺痛、发麻或关节卡住感时立即停止。' },
|
||||
{ title: '站立时少用膝盖锁死来“站直”', detail: '膝盖微微有弹性,让髋和核心一起承担站姿。', stopIf: '膝盖疼痛或不稳时不要继续。' },
|
||||
{ title: '训练时优先关注髋部活动与单腿稳定', detail: '先找回髋部的活动空间,再谈更高强度的下肢动作。', stopIf: '单腿站立明显失控或疼痛时停止,改到店评估。' },
|
||||
],
|
||||
}
|
||||
}
|
||||
if (type === PortraitType.SEDENTARY_CORE_WEAK || type === PortraitType.LOW_OVERALL_CONCERN) {
|
||||
return {
|
||||
id: 'core_pack',
|
||||
items: [
|
||||
{ title: '久坐时留意骨盆中立', detail: '避免长时间塌腰或过度挺腹,让躯干更均匀地参与支撑。', stopIf: '腰痛明显加重时停止,先改变姿势或起身。' },
|
||||
{ title: '每天安排 5 组轻柔呼吸激活', detail: '用鼻吸口呼,让肋骨和腹部轻轻配合,而不是憋气挺腹。', stopIf: '头晕、胸闷或疼痛时停止。' },
|
||||
{ title: '训练时优先关注核心参与', detail: '一些动作里身体可能更多依赖腰背,可以先放慢,让躯干整体参与。', stopIf: '腰部出现尖锐疼痛时立即停止。' },
|
||||
],
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: 'neck_pack',
|
||||
items: [
|
||||
{ title: '每坐 45~60 分钟起来活动一次', detail: '短暂走动或做肩胛活动,减少肩颈持续承担支撑。', stopIf: '头晕、手臂发麻或疼痛加重时停止。' },
|
||||
{ title: '每天安排 3~5 分钟上背活动', detail: '用缓慢的扩展和旋转,让上背部重新参与,而不是只拉伸脖子。', stopIf: '出现放射痛、明显弹响伴随疼痛时停止。' },
|
||||
{ title: '训练时优先关注呼吸与肩胛运动', detail: '肩颈紧并不只是局部问题,通常需要同时看到胸椎、呼吸和肩胛。', stopIf: '颈部刺痛或手脚发麻时停止,并先确认运动条件。' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function chainFor(type: PortraitType, answers: BodyPortraitAnswers): BodyPortraitChain {
|
||||
if (type === PortraitType.HIP_TIGHT || type === PortraitType.LOWER_LIMB_UNSTABLE) {
|
||||
return {
|
||||
steps: ['久坐或单侧承重', '髋部活动变少', '骨盆更难保持中立', '膝盖或腰更容易代偿'],
|
||||
takeaway: '所以只拉伸腿部往往只能短暂轻松。训练通常需要同时关注髋部活动、骨盆控制和单腿稳定。',
|
||||
}
|
||||
}
|
||||
if (type === PortraitType.SEDENTARY_CORE_WEAK) {
|
||||
return {
|
||||
steps: ['长时间坐姿', '核心参与减少', '腰背更容易代偿', '站立或动作时腹部向前顶'],
|
||||
takeaway: '所以只做腹肌练习不一定能让久坐更轻松。训练通常需要先找回呼吸、骨盆控制和躯干整体参与。',
|
||||
}
|
||||
}
|
||||
if (answers.workPosture === WorkPosture.PHONE) {
|
||||
return {
|
||||
steps: ['频繁低头看手机', '头部位置前移', '肩部更容易向内', '肩颈承担更多压力'],
|
||||
takeaway: '所以仅仅按摩肩颈往往只能短暂舒服。训练通常需要同时关注头部位置、肩胛运动和上背活动。',
|
||||
}
|
||||
}
|
||||
return {
|
||||
steps: ['久坐', '胸椎活动减少', '肩部更容易向前', '头部位置改变', '肩颈承担更多压力'],
|
||||
takeaway: '所以仅仅按摩肩颈往往只能短暂舒服。训练通常需要同时关注胸椎活动、呼吸、核心控制以及肩胛运动模式。',
|
||||
}
|
||||
}
|
||||
|
||||
function headlineFor(primary: PortraitType, secondary: PortraitType | null): string {
|
||||
const main = PORTRAIT_TYPE_LABELS[primary]
|
||||
if (!secondary || secondary === primary) return main
|
||||
return `${main} + ${PORTRAIT_TYPE_LABELS[secondary]}`
|
||||
}
|
||||
|
||||
function summaryFor(answers: BodyPortraitAnswers, primary: PortraitType, safety: BodyPortraitSafetyResult): string {
|
||||
if (safety.flagged) {
|
||||
return '根据你的回答,当前更适合先确认运动条件,而不是继续给出训练建议。线上问卷只能反映主观感受,不能代替专业医疗评估。'
|
||||
}
|
||||
const goal = answers.goal ? PORTRAIT_GOAL_LABELS[answers.goal] : null
|
||||
if (primary === PortraitType.SEDENTARY_NECK_TENSION) {
|
||||
return goal
|
||||
? `如果你的目标是${goal},目前最值得关注的是肩颈、上背活动和核心控制。你的身体并不是单纯“肩颈不好”,更可能是长时间坐姿让肩颈承担了更多工作。`
|
||||
: '你的身体并不是单纯“肩颈不好”,更可能是长时间坐姿、上背活动减少以及核心参与不足,让肩颈承担了更多工作。'
|
||||
}
|
||||
if (primary === PortraitType.SEDENTARY_CORE_WEAK) {
|
||||
return goal
|
||||
? `如果你的目标是${goal},目前更值得关注的是核心参与和腰背是否在代偿,而不是先追求更高强度。`
|
||||
: '一些动作中身体可能更多依赖腰背,而不是躯干整体参与。久坐会让这种情况更容易出现。'
|
||||
}
|
||||
if (primary === PortraitType.HIP_TIGHT) {
|
||||
return goal
|
||||
? `如果你的目标是${goal},目前最值得关注的是髋部灵活性和骨盆控制。`
|
||||
: '髋部活动变少时,腰和膝常常会帮忙代偿。这值得进一步观察,而不是先下结论。'
|
||||
}
|
||||
if (primary === PortraitType.LOWER_LIMB_UNSTABLE) {
|
||||
return goal
|
||||
? `如果你的目标是${goal},目前更值得关注下肢稳定和髋部控制。`
|
||||
: '单侧承重或膝盖锁死,会让下肢更难稳定地完成日常动作。'
|
||||
}
|
||||
if (primary === PortraitType.LOW_OVERALL_CONCERN) {
|
||||
return '根据当前问卷,整体关注度不高。若你仍有具体目标,到店评估会比线上问卷更能看出动作细节。'
|
||||
}
|
||||
return goal
|
||||
? `如果你的目标是${goal},目前最值得关注的是几个区域如何互相影响,而不是只处理最酸的那一处。`
|
||||
: '肩颈、核心和髋部往往一起变化。线上问卷能帮我们抓住优先关注点,真正如何运动还需要现场观察。'
|
||||
}
|
||||
|
||||
function hintsFor(type: PortraitType): string[] {
|
||||
if (type === PortraitType.HIP_TIGHT || type === PortraitType.LOWER_LIMB_UNSTABLE) {
|
||||
return ['骨盆位置', '髋部活动', '单腿稳定', '呼吸模式', '深蹲控制']
|
||||
}
|
||||
if (type === PortraitType.SEDENTARY_CORE_WEAK) {
|
||||
return ['呼吸模式', '骨盆位置', 'Dead Bug 控制', '胸椎伸展', '自然站姿']
|
||||
}
|
||||
return ['自然站姿头部位置', '肩胛位置', '胸椎伸展', '肩屈活动', '呼吸模式']
|
||||
}
|
||||
|
||||
function answeredCount(answers: BodyPortraitAnswers): number {
|
||||
return [
|
||||
answers.concerns.length > 0,
|
||||
!!answers.goal,
|
||||
!!answers.sittingHours,
|
||||
!!answers.exerciseFreq,
|
||||
!!answers.workPosture,
|
||||
answers.endOfDayFatigue.length > 0,
|
||||
answers.afterSitting.length > 0,
|
||||
answers.standingNotice.length > 0,
|
||||
answers.safety.length > 0,
|
||||
].filter(Boolean).length
|
||||
}
|
||||
|
||||
export function normalizeAnswers(input: Partial<BodyPortraitAnswers> | null | undefined): BodyPortraitAnswers {
|
||||
const source = input || {}
|
||||
return {
|
||||
concerns: uniqueEnums(source.concerns, REGION_VALUES),
|
||||
goal: pickEnum(source.goal, GOAL_VALUES),
|
||||
sittingHours: pickEnum(source.sittingHours, SITTING_VALUES),
|
||||
exerciseFreq: pickEnum(source.exerciseFreq, EXERCISE_VALUES),
|
||||
workPosture: pickEnum(source.workPosture, WORK_VALUES),
|
||||
endOfDayFatigue: uniqueEnums(source.endOfDayFatigue, REGION_VALUES),
|
||||
afterSitting: uniqueEnums(source.afterSitting, AFTER_VALUES),
|
||||
standingNotice: uniqueEnums(source.standingNotice, STANDING_VALUES),
|
||||
safety: uniqueEnums(source.safety, SAFETY_VALUES),
|
||||
}
|
||||
}
|
||||
|
||||
function uniqueEnums<T>(values: readonly T[] | undefined, allowed: Set<string>): T[] {
|
||||
if (!Array.isArray(values)) return []
|
||||
const seen = new Set<string>()
|
||||
const result: T[] = []
|
||||
for (const value of values) {
|
||||
if (typeof value !== 'string' || !allowed.has(value) || seen.has(value)) continue
|
||||
seen.add(value)
|
||||
result.push(value as T)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function pickEnum<T>(value: T | null | undefined, allowed: Set<string>): T | null {
|
||||
if (typeof value !== 'string' || !allowed.has(value)) return null
|
||||
return value as T
|
||||
}
|
||||
|
||||
export function scorePortrait(raw: Partial<BodyPortraitAnswers> | null | undefined): BodyPortraitReport {
|
||||
const answers = normalizeAnswers(raw)
|
||||
const scores = emptyScores()
|
||||
const evidence: EvidenceCandidate[] = []
|
||||
|
||||
applyRegions(scores, answers.concerns, 8, evidence, '你标注的困扰区域', '这些部位是你目前最在意的地方,会作为优先关注线索。')
|
||||
applyRegions(scores, answers.endOfDayFatigue, 6, evidence, '一天结束时更容易累的地方', '疲劳部位帮助我们理解压力如何在一天里累积。')
|
||||
|
||||
if (answers.goal === PortraitGoal.POSTURE) {
|
||||
add(scores, BodyDimension.CERVICAL_SHOULDER, 10)
|
||||
add(scores, BodyDimension.SPINAL_MOBILITY, 8)
|
||||
add(scores, BodyDimension.CORE_CONTROL, 8)
|
||||
evidence.push({ dimension: BodyDimension.CERVICAL_SHOULDER, title: '你希望看起来更挺拔', answers: [PORTRAIT_GOAL_LABELS[answers.goal]], explanation: '挺拔通常同时涉及肩颈位置、上背活动和核心控制。', weight: 10 })
|
||||
} else if (answers.goal === PortraitGoal.NECK_RELIEF) {
|
||||
add(scores, BodyDimension.CERVICAL_SHOULDER, 12)
|
||||
evidence.push({ dimension: BodyDimension.CERVICAL_SHOULDER, title: '你希望肩颈没那么累', answers: [PORTRAIT_GOAL_LABELS[answers.goal]], explanation: '肩颈疲劳往往不是局部问题,需要看坐姿和上背是否在帮忙。', weight: 12 })
|
||||
} else if (answers.goal === PortraitGoal.BACK_COMFORT) {
|
||||
add(scores, BodyDimension.SPINAL_MOBILITY, 10)
|
||||
add(scores, BodyDimension.CORE_CONTROL, 8)
|
||||
evidence.push({ dimension: BodyDimension.SPINAL_MOBILITY, title: '你希望久坐更舒服', answers: [PORTRAIT_GOAL_LABELS[answers.goal]], explanation: '久坐不适通常和上背活动、核心参与一起出现。', weight: 10 })
|
||||
} else if (answers.goal === PortraitGoal.CORE) {
|
||||
add(scores, BodyDimension.CORE_CONTROL, 12)
|
||||
evidence.push({ dimension: BodyDimension.CORE_CONTROL, title: '你希望腹部更有力量', answers: [PORTRAIT_GOAL_LABELS[answers.goal]], explanation: '核心控制指的是躯干整体参与,而不是单纯“腹肌薄弱”。', weight: 12 })
|
||||
} else if (answers.goal === PortraitGoal.SHAPING) {
|
||||
add(scores, BodyDimension.CORE_CONTROL, 8)
|
||||
add(scores, BodyDimension.HIP_PELVIS, 6)
|
||||
} else if (answers.goal === PortraitGoal.HIP_LEG) {
|
||||
add(scores, BodyDimension.HIP_PELVIS, 12)
|
||||
add(scores, BodyDimension.LOWER_LIMB, 8)
|
||||
evidence.push({ dimension: BodyDimension.HIP_PELVIS, title: '你希望改善髋腿状态', answers: [PORTRAIT_GOAL_LABELS[answers.goal]], explanation: '髋部灵活性和下肢稳定会一起影响腿型与日常动作。', weight: 12 })
|
||||
} else if (answers.goal === PortraitGoal.STABILITY) {
|
||||
add(scores, BodyDimension.LOWER_LIMB, 10)
|
||||
add(scores, BodyDimension.CORE_CONTROL, 8)
|
||||
}
|
||||
|
||||
if (answers.sittingHours === SittingHours.GT8) {
|
||||
add(scores, BodyDimension.CERVICAL_SHOULDER, 12)
|
||||
add(scores, BodyDimension.SPINAL_MOBILITY, 10)
|
||||
add(scores, BodyDimension.CORE_CONTROL, 8)
|
||||
evidence.push({ dimension: BodyDimension.CERVICAL_SHOULDER, title: '每天坐超过 8 小时', answers: [SITTING_HOURS_LABELS[answers.sittingHours]], explanation: '长时间坐姿会减少上背活动,让肩颈和腰背承担更多。', weight: 12 })
|
||||
} else if (answers.sittingHours === SittingHours.H6_TO_8) {
|
||||
add(scores, BodyDimension.CERVICAL_SHOULDER, 8)
|
||||
add(scores, BodyDimension.SPINAL_MOBILITY, 6)
|
||||
add(scores, BodyDimension.CORE_CONTROL, 6)
|
||||
evidence.push({ dimension: BodyDimension.CERVICAL_SHOULDER, title: '每天坐 6~8 小时', answers: [SITTING_HOURS_LABELS[answers.sittingHours]], explanation: '中长时间坐姿已经足以改变肩颈和核心的工作方式。', weight: 8 })
|
||||
} else if (answers.sittingHours === SittingHours.H4_TO_6) {
|
||||
add(scores, BodyDimension.CERVICAL_SHOULDER, 4)
|
||||
add(scores, BodyDimension.SPINAL_MOBILITY, 3)
|
||||
add(scores, BodyDimension.CORE_CONTROL, 3)
|
||||
}
|
||||
|
||||
if (answers.exerciseFreq === ExerciseFreq.NONE) {
|
||||
add(scores, BodyDimension.CERVICAL_SHOULDER, 8)
|
||||
add(scores, BodyDimension.SPINAL_MOBILITY, 8)
|
||||
add(scores, BodyDimension.CORE_CONTROL, 8)
|
||||
add(scores, BodyDimension.HIP_PELVIS, 8)
|
||||
add(scores, BodyDimension.LOWER_LIMB, 8)
|
||||
evidence.push({ dimension: BodyDimension.CORE_CONTROL, title: '目前几乎不运动', answers: [EXERCISE_FREQ_LABELS[answers.exerciseFreq]], explanation: '缺少规律活动时,身体更容易用局部紧张来维持姿势。', weight: 8 })
|
||||
} else if (answers.exerciseFreq === ExerciseFreq.W1_TO_2) {
|
||||
add(scores, BodyDimension.CERVICAL_SHOULDER, 4)
|
||||
add(scores, BodyDimension.CORE_CONTROL, 4)
|
||||
add(scores, BodyDimension.HIP_PELVIS, 4)
|
||||
} else if (answers.exerciseFreq === ExerciseFreq.W5_PLUS) {
|
||||
add(scores, BodyDimension.CERVICAL_SHOULDER, -4)
|
||||
add(scores, BodyDimension.SPINAL_MOBILITY, -4)
|
||||
add(scores, BodyDimension.CORE_CONTROL, -4)
|
||||
add(scores, BodyDimension.HIP_PELVIS, -4)
|
||||
add(scores, BodyDimension.LOWER_LIMB, -4)
|
||||
}
|
||||
|
||||
if (answers.workPosture === WorkPosture.COMPUTER) {
|
||||
add(scores, BodyDimension.CERVICAL_SHOULDER, 8)
|
||||
add(scores, BodyDimension.SPINAL_MOBILITY, 6)
|
||||
evidence.push({ dimension: BodyDimension.CERVICAL_SHOULDER, title: '工作时以电脑办公为主', answers: [WORK_POSTURE_LABELS[answers.workPosture]], explanation: '屏幕前的固定姿势会让肩颈和上背持续处于同一模式。', weight: 8 })
|
||||
} else if (answers.workPosture === WorkPosture.PHONE) {
|
||||
add(scores, BodyDimension.CERVICAL_SHOULDER, 10)
|
||||
evidence.push({ dimension: BodyDimension.CERVICAL_SHOULDER, title: '经常低头使用手机', answers: [WORK_POSTURE_LABELS[answers.workPosture]], explanation: '头部前移会让肩颈持续多做功。', weight: 10 })
|
||||
} else if (answers.workPosture === WorkPosture.STANDING) {
|
||||
add(scores, BodyDimension.LOWER_LIMB, 8)
|
||||
add(scores, BodyDimension.HIP_PELVIS, 4)
|
||||
} else if (answers.workPosture === WorkPosture.HOLDING_CHILD) {
|
||||
add(scores, BodyDimension.CORE_CONTROL, 8)
|
||||
add(scores, BodyDimension.HIP_PELVIS, 8)
|
||||
add(scores, BodyDimension.CERVICAL_SHOULDER, 6)
|
||||
}
|
||||
|
||||
const after = answers.afterSitting.filter((item) => item !== AfterSitting.NONE)
|
||||
if (after.includes(AfterSitting.NECK_TIGHT)) {
|
||||
add(scores, BodyDimension.CERVICAL_SHOULDER, 14)
|
||||
evidence.push({ dimension: BodyDimension.CERVICAL_SHOULDER, title: '久坐后肩颈容易紧', answers: [AFTER_SITTING_LABELS[AfterSitting.NECK_TIGHT]], explanation: '这是肩颈正在承担过多支撑的常见感受。', weight: 14 })
|
||||
}
|
||||
if (after.includes(AfterSitting.LOW_BACK_ACHE)) {
|
||||
add(scores, BodyDimension.CORE_CONTROL, 10)
|
||||
add(scores, BodyDimension.HIP_PELVIS, 6)
|
||||
add(scores, BodyDimension.SPINAL_MOBILITY, 6)
|
||||
evidence.push({ dimension: BodyDimension.CORE_CONTROL, title: '久坐后容易腰酸', answers: [AFTER_SITTING_LABELS[AfterSitting.LOW_BACK_ACHE]], explanation: '腰酸常常提示身体更多依赖腰背,而不是躯干整体参与。', weight: 10 })
|
||||
}
|
||||
if (after.includes(AfterSitting.BACK_STIFF)) {
|
||||
add(scores, BodyDimension.SPINAL_MOBILITY, 12)
|
||||
evidence.push({ dimension: BodyDimension.SPINAL_MOBILITY, title: '坐久后背部发僵', answers: [AFTER_SITTING_LABELS[AfterSitting.BACK_STIFF]], explanation: '上背部参与变少时,挺胸或抬臂会更容易觉得受限。', weight: 12 })
|
||||
}
|
||||
if (after.includes(AfterSitting.HIP_TIGHT)) {
|
||||
add(scores, BodyDimension.HIP_PELVIS, 12)
|
||||
add(scores, BodyDimension.LOWER_LIMB, 6)
|
||||
evidence.push({ dimension: BodyDimension.HIP_PELVIS, title: '久坐后臀腿发紧', answers: [AFTER_SITTING_LABELS[AfterSitting.HIP_TIGHT]], explanation: '髋部活动变少后,下肢和腰会更容易代偿。', weight: 12 })
|
||||
}
|
||||
|
||||
const standing = answers.standingNotice.filter((item) => item !== StandingNotice.UNNOTICED)
|
||||
if (standing.includes(StandingNotice.HEAD_FORWARD)) {
|
||||
add(scores, BodyDimension.CERVICAL_SHOULDER, 10)
|
||||
evidence.push({ dimension: BodyDimension.CERVICAL_SHOULDER, title: '自然站立时头容易往前', answers: [STANDING_NOTICE_LABELS[StandingNotice.HEAD_FORWARD]], explanation: '头部位置前移会持续增加肩颈负担。', weight: 10 })
|
||||
}
|
||||
if (standing.includes(StandingNotice.ROUNDED_SHOULDERS)) {
|
||||
add(scores, BodyDimension.CERVICAL_SHOULDER, 10)
|
||||
add(scores, BodyDimension.SPINAL_MOBILITY, 6)
|
||||
evidence.push({ dimension: BodyDimension.CERVICAL_SHOULDER, title: '肩膀容易向内', answers: [STANDING_NOTICE_LABELS[StandingNotice.ROUNDED_SHOULDERS]], explanation: '肩部向前时,上背部往往会越来越少参与活动。', weight: 10 })
|
||||
}
|
||||
if (standing.includes(StandingNotice.UNEVEN_SHOULDERS)) {
|
||||
add(scores, BodyDimension.CERVICAL_SHOULDER, 6)
|
||||
}
|
||||
if (standing.includes(StandingNotice.BELLY_FORWARD)) {
|
||||
add(scores, BodyDimension.CORE_CONTROL, 14)
|
||||
add(scores, BodyDimension.HIP_PELVIS, 8)
|
||||
evidence.push({ dimension: BodyDimension.CORE_CONTROL, title: '站立时腹部容易向前突出', answers: [STANDING_NOTICE_LABELS[StandingNotice.BELLY_FORWARD]], explanation: '这提示核心和骨盆可能没有一起参与站姿,而不是简单的“肚子大”。', weight: 14 })
|
||||
}
|
||||
if (standing.includes(StandingNotice.LOCKED_KNEES)) {
|
||||
add(scores, BodyDimension.LOWER_LIMB, 12)
|
||||
evidence.push({ dimension: BodyDimension.LOWER_LIMB, title: '站立时膝盖容易锁死', answers: [STANDING_NOTICE_LABELS[StandingNotice.LOCKED_KNEES]], explanation: '用膝盖锁死来站直,会让下肢稳定和髋部控制都变少。', weight: 12 })
|
||||
}
|
||||
|
||||
const normalized: BodyPortraitScores = {
|
||||
cervicalShoulder: clamp(scores.cervicalShoulder),
|
||||
spinalMobility: clamp(scores.spinalMobility),
|
||||
coreControl: clamp(scores.coreControl),
|
||||
hipPelvis: clamp(scores.hipPelvis),
|
||||
lowerLimb: clamp(scores.lowerLimb),
|
||||
}
|
||||
const levels: BodyPortraitLevels = {
|
||||
cervicalShoulder: concernLevel(normalized.cervicalShoulder),
|
||||
spinalMobility: concernLevel(normalized.spinalMobility),
|
||||
coreControl: concernLevel(normalized.coreControl),
|
||||
hipPelvis: concernLevel(normalized.hipPelvis),
|
||||
lowerLimb: concernLevel(normalized.lowerLimb),
|
||||
}
|
||||
const { primary, secondary } = pickType(normalized, answers)
|
||||
const flags = safetyFlagsOf(answers)
|
||||
const safety: BodyPortraitSafetyResult = {
|
||||
flagged: flags.length > 0,
|
||||
flags,
|
||||
message: flags.length
|
||||
? '建议先由医生或相应专业人士确认运动条件,再进行训练。线上问卷不能判断伤病或康复进度。'
|
||||
: null,
|
||||
}
|
||||
const rankedEvidence = evidence
|
||||
.sort((a, b) => b.weight - a.weight)
|
||||
.filter((item, index, arr) => arr.findIndex((other) => other.title === item.title) === index)
|
||||
.slice(0, 3)
|
||||
.map(({ dimension, title, answers: used, explanation }): BodyPortraitEvidence => ({ dimension, title, answers: used, explanation }))
|
||||
const matchQuality = answeredCount(answers) >= 8 && rankedEvidence.length >= 2
|
||||
? PortraitMatchQuality.FULL
|
||||
: PortraitMatchQuality.PARTIAL
|
||||
|
||||
return {
|
||||
questionnaireVersion: BODY_PORTRAIT_QUESTIONNAIRE_VERSION,
|
||||
rulesVersion: BODY_PORTRAIT_RULES_VERSION,
|
||||
scores: normalized,
|
||||
levels,
|
||||
primaryType: primary,
|
||||
secondaryType: secondary,
|
||||
headline: headlineFor(primary, secondary),
|
||||
summary: summaryFor(answers, primary, safety),
|
||||
evidence: rankedEvidence,
|
||||
chain: chainFor(primary, answers),
|
||||
advice: adviceFor(primary, safety.flagged),
|
||||
matchQuality,
|
||||
safety,
|
||||
firstAssessmentHints: hintsFor(primary),
|
||||
goalLabel: answers.goal ? PORTRAIT_GOAL_LABELS[answers.goal] : null,
|
||||
}
|
||||
}
|
||||
|
||||
export function toTeaser(assessmentId: string, report: BodyPortraitReport) {
|
||||
const top = DIMENSIONS
|
||||
.map((dimension) => [dimension, report.scores[dimension]] as const)
|
||||
.sort((a, b) => b[1] - a[1])[0]
|
||||
return {
|
||||
assessmentId,
|
||||
headline: report.headline,
|
||||
primaryType: report.primaryType,
|
||||
primaryTypeLabel: PORTRAIT_TYPE_LABELS[report.primaryType],
|
||||
topDimension: top[1] > 0 ? top[0] : null,
|
||||
topDimensionLabel: top[1] > 0 ? BODY_DIMENSION_LABELS[top[0]] : null,
|
||||
matchQuality: report.matchQuality,
|
||||
safetyFlagged: report.safety.flagged,
|
||||
disclaimer: BODY_PORTRAIT_DISCLAIMER,
|
||||
}
|
||||
}
|
||||
|
||||
export { BODY_PORTRAIT_DISCLAIMER }
|
||||
@@ -160,6 +160,11 @@ function buildTxMock(overrides: Record<string, unknown> = {}) {
|
||||
bookingStatusHistory: {
|
||||
create: jest.fn(),
|
||||
},
|
||||
bodyPortraitAssessment: {
|
||||
findUnique: jest.fn(),
|
||||
findFirst: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -476,6 +481,58 @@ describe('BookingService', () => {
|
||||
expect(result).toBeDefined()
|
||||
})
|
||||
|
||||
it('binds anonymous originAssessmentId to current user on booking create', async () => {
|
||||
const tx = buildTxMock()
|
||||
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
||||
tx.booking.findUnique.mockResolvedValue(null)
|
||||
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
|
||||
tx.bodyPortraitAssessment.findUnique.mockResolvedValue({ id: 'anon-assess', userId: null })
|
||||
tx.bodyPortraitAssessment.update.mockResolvedValue({ id: 'anon-assess', userId: MOCK_USER_ID })
|
||||
tx.booking.create.mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
originAssessmentId: 'anon-assess',
|
||||
status: BookingStatus.PENDING_CONFIRMATION,
|
||||
})
|
||||
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
originAssessmentId: 'anon-assess',
|
||||
status: BookingStatus.PENDING_CONFIRMATION,
|
||||
timeSlot: mockOpenSlot,
|
||||
membership: mockActiveMembership,
|
||||
})
|
||||
;(prisma.user.findMany as jest.Mock).mockResolvedValue([])
|
||||
|
||||
await service.createBooking(MOCK_USER_ID, { ...dto, originAssessmentId: 'anon-assess' })
|
||||
|
||||
expect(tx.bodyPortraitAssessment.update).toHaveBeenCalledWith({
|
||||
where: { id: 'anon-assess' },
|
||||
data: { userId: MOCK_USER_ID, status: 'CLAIMED', claimedAt: expect.any(Date) },
|
||||
})
|
||||
expect(tx.booking.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
originAssessmentId: 'anon-assess',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects originAssessmentId belonging to another user', async () => {
|
||||
const tx = buildTxMock()
|
||||
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
||||
tx.booking.findUnique.mockResolvedValue(null)
|
||||
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
|
||||
tx.bodyPortraitAssessment.findUnique.mockResolvedValue({ id: 'other-assess', userId: 'someone-else' })
|
||||
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
|
||||
await expect(
|
||||
service.createBooking(MOCK_USER_ID, { ...dto, originAssessmentId: 'other-assess' }),
|
||||
).rejects.toThrow('画像报告不属于当前用户')
|
||||
})
|
||||
|
||||
it('records booking status history when user creates a booking', async () => {
|
||||
const nearFullSlot = { ...mockOpenSlot, bookedCount: 4, capacity: 5 }
|
||||
|
||||
|
||||
@@ -7,9 +7,10 @@ import { MembershipModule } from '../membership/membership.module'
|
||||
import { StudioModule } from '../studio/studio.module'
|
||||
import { UserModule } from '../user/user.module'
|
||||
import { InviteModule } from '../invite/invite.module'
|
||||
import { BodyPortraitModule } from '../body-portrait/body-portrait.module'
|
||||
|
||||
@Module({
|
||||
imports: [MembershipModule, StudioModule, UserModule, InviteModule],
|
||||
imports: [MembershipModule, StudioModule, UserModule, InviteModule, BodyPortraitModule],
|
||||
controllers: [BookingController, ReviewController, PublicReviewController],
|
||||
providers: [BookingService, ReviewService],
|
||||
exports: [BookingService],
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
Optional,
|
||||
} from '@nestjs/common'
|
||||
import { Booking, Membership, TimeSlot, BookingStatusHistory } from '@prisma/client'
|
||||
import {
|
||||
@@ -21,6 +22,7 @@ import { SubscriptionMessageService } from '../user/subscription-message.service
|
||||
import { CreateBookingDto } from './dto/create-booking.dto'
|
||||
import { AdminArrangeBookingDto } from './dto/admin-arrange-booking.dto'
|
||||
import { InviteService } from '../invite/invite.service'
|
||||
import { BodyPortraitLifecycleService } from '../body-portrait/body-portrait-lifecycle.service'
|
||||
|
||||
// ─── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -64,6 +66,7 @@ export class BookingService {
|
||||
private readonly studioService: StudioService,
|
||||
private readonly subscriptionMessageService: SubscriptionMessageService,
|
||||
private readonly inviteService: InviteService,
|
||||
@Optional() private readonly portraitLifecycle?: BodyPortraitLifecycleService,
|
||||
) {}
|
||||
|
||||
// ─── Create Booking ──────────────────────────────────────────────────────
|
||||
@@ -125,6 +128,28 @@ export class BookingService {
|
||||
throw new BadRequestException('Membership has expired')
|
||||
}
|
||||
|
||||
let originAssessmentId: string | undefined
|
||||
if (dto.originAssessmentId) {
|
||||
const assessment = await tx.bodyPortraitAssessment.findUnique({
|
||||
where: { id: dto.originAssessmentId },
|
||||
select: { id: true, userId: true },
|
||||
})
|
||||
if (assessment) {
|
||||
if (!assessment.userId) {
|
||||
// 匿名画像在约课时绑定给当前用户
|
||||
await tx.bodyPortraitAssessment.update({
|
||||
where: { id: assessment.id },
|
||||
data: { userId, status: 'CLAIMED', claimedAt: new Date() },
|
||||
})
|
||||
originAssessmentId = assessment.id
|
||||
} else if (assessment.userId === userId) {
|
||||
originAssessmentId = assessment.id
|
||||
} else {
|
||||
throw new ForbiddenException('画像报告不属于当前用户')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Create booking or revive a previously cancelled booking.
|
||||
const newBooking = existing?.status === BookingStatus.CANCELLED
|
||||
? await tx.booking.update({
|
||||
@@ -137,6 +162,7 @@ export class BookingService {
|
||||
confirmedAt: null,
|
||||
completedAt: null,
|
||||
operatorId: null,
|
||||
originAssessmentId: originAssessmentId || existing.originAssessmentId,
|
||||
},
|
||||
})
|
||||
: await tx.booking.create({
|
||||
@@ -146,6 +172,7 @@ export class BookingService {
|
||||
membershipId: dto.membershipId,
|
||||
membershipTimesDeducted: false,
|
||||
status: BookingStatus.PENDING_CONFIRMATION,
|
||||
originAssessmentId,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -170,6 +197,12 @@ export class BookingService {
|
||||
// Re-fetch with relations after transaction
|
||||
const bookingWithRelations = await this.fetchBookingWithRelations(booking.id)
|
||||
await this.trySendAdminBookingCreatedSubscriptionMessages(bookingWithRelations)
|
||||
await this.portraitLifecycle?.onBookingCreated(
|
||||
userId,
|
||||
bookingWithRelations.id,
|
||||
bookingWithRelations.membership.cardType.type,
|
||||
bookingWithRelations.originAssessmentId,
|
||||
)
|
||||
return bookingWithRelations
|
||||
}
|
||||
|
||||
@@ -484,6 +517,11 @@ export class BookingService {
|
||||
const result = await this.fetchBookingWithRelations(booking.id)
|
||||
if (toStatus === BookingStatus.COMPLETED) {
|
||||
await this.inviteService.recordQualifiedTrialBooking(result.id)
|
||||
await this.portraitLifecycle?.onBookingCompleted(
|
||||
result.userId,
|
||||
result.id,
|
||||
result.membership.cardType.type,
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsUUID } from 'class-validator'
|
||||
import { IsOptional, IsUUID } from 'class-validator'
|
||||
|
||||
export class CreateBookingDto {
|
||||
@IsUUID()
|
||||
@@ -6,4 +6,8 @@ export class CreateBookingDto {
|
||||
|
||||
@IsUUID()
|
||||
membershipId!: string
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
originAssessmentId?: string
|
||||
}
|
||||
|
||||
@@ -5,9 +5,10 @@ import { PaymentController } from './payment.controller'
|
||||
import { WechatPayService } from './wechat-pay.service'
|
||||
import { InviteModule } from '../invite/invite.module'
|
||||
import { MembershipModule } from '../membership/membership.module'
|
||||
import { BodyPortraitModule } from '../body-portrait/body-portrait.module'
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, InviteModule, MembershipModule],
|
||||
imports: [PrismaModule, InviteModule, MembershipModule, BodyPortraitModule],
|
||||
controllers: [PaymentController],
|
||||
providers: [PaymentService, WechatPayService],
|
||||
exports: [PaymentService, WechatPayService],
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
Optional,
|
||||
} from '@nestjs/common'
|
||||
import { CardType, Order } from '@prisma/client'
|
||||
import { OrderStatus } from '@mp-pilates/shared'
|
||||
@@ -10,6 +11,7 @@ import { PrismaService } from '../prisma/prisma.service'
|
||||
import { WechatPayService, WxPaymentParams } from './wechat-pay.service'
|
||||
import { InviteService } from '../invite/invite.service'
|
||||
import { MembershipService } from '../membership/membership.service'
|
||||
import { BodyPortraitLifecycleService } from '../body-portrait/body-portrait-lifecycle.service'
|
||||
|
||||
export interface CreateOrderResult {
|
||||
order: Order
|
||||
@@ -32,6 +34,7 @@ export class PaymentService {
|
||||
private readonly wechatPayService: WechatPayService,
|
||||
private readonly inviteService: InviteService,
|
||||
private readonly membershipService: MembershipService,
|
||||
@Optional() private readonly portraitLifecycle?: BodyPortraitLifecycleService,
|
||||
) {}
|
||||
|
||||
// ─── User: create order ────────────────────────────────────────────────────
|
||||
@@ -151,6 +154,7 @@ export class PaymentService {
|
||||
})
|
||||
|
||||
await this.inviteService.recordTrialOrderPaid(existingOrder.id)
|
||||
await this.portraitLifecycle?.onOrderPaid(existingOrder.userId, cardType.type, existingOrder.id)
|
||||
|
||||
this.logger.log(`Order PAID and membership granted: orderNo=${notification.orderNo}`)
|
||||
|
||||
|
||||
@@ -32,6 +32,17 @@ export class SchedulerService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 02:35 daily — delete past CLOSED slots with no bookings attached */
|
||||
@Cron('35 2 * * *')
|
||||
async handlePrunePastClosedSlots(): Promise<void> {
|
||||
try {
|
||||
const count = await this.slotGenerator.prunePastClosedSlots()
|
||||
this.logger.log(`[handlePrunePastClosedSlots] Pruned ${count} past closed slots`)
|
||||
} catch (err) {
|
||||
this.logger.error('[handlePrunePastClosedSlots] Failed to prune slots', err)
|
||||
}
|
||||
}
|
||||
|
||||
/** 03:00 daily — expire memberships past their end date or with 0 sessions */
|
||||
@Cron('0 3 * * *')
|
||||
async handleCheckMemberships(): Promise<void> {
|
||||
|
||||
@@ -15,8 +15,10 @@ import {
|
||||
|
||||
const mockPrisma = {
|
||||
timeSlot: {
|
||||
findMany: jest.fn(),
|
||||
createMany: jest.fn(),
|
||||
updateMany: jest.fn(),
|
||||
deleteMany: jest.fn(),
|
||||
},
|
||||
membership: {
|
||||
updateMany: jest.fn(),
|
||||
@@ -35,6 +37,8 @@ describe('SlotGeneratorService', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks()
|
||||
// Default: no dates are pre-touched by an admin. Individual tests override.
|
||||
mockPrisma.timeSlot.findMany.mockResolvedValue([])
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
@@ -68,21 +72,25 @@ describe('SlotGeneratorService', () => {
|
||||
expect(count).toBe(defaultSlots.length * 7)
|
||||
})
|
||||
|
||||
it('creates 13 slots per day (08:00-09:00, then 09:30-21:30 hourly)', async () => {
|
||||
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 13 })
|
||||
it('creates 12 slots per day (morning on the hour, afternoon from 13:30)', async () => {
|
||||
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 12 })
|
||||
|
||||
await service.generateSlots(1)
|
||||
|
||||
const { data } = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
|
||||
data: Array<{ startTime: string; endTime: string }>
|
||||
}
|
||||
expect(data).toHaveLength(13)
|
||||
expect(data).toHaveLength(12)
|
||||
expect(data[0].startTime).toBe('08:00')
|
||||
expect(data[0].endTime).toBe('09:00')
|
||||
expect(data[1].startTime).toBe('09:30')
|
||||
expect(data[1].endTime).toBe('10:30')
|
||||
expect(data[12].startTime).toBe('20:30')
|
||||
expect(data[12].endTime).toBe('21:30')
|
||||
expect(data[1].startTime).toBe('09:00')
|
||||
expect(data[1].endTime).toBe('10:00')
|
||||
expect(data[3].startTime).toBe('11:00')
|
||||
expect(data[3].endTime).toBe('12:00')
|
||||
expect(data[4].startTime).toBe('13:30')
|
||||
expect(data[4].endTime).toBe('14:30')
|
||||
expect(data[11].startTime).toBe('20:30')
|
||||
expect(data[11].endTime).toBe('21:30')
|
||||
})
|
||||
|
||||
it('passes skipDuplicates: true to handle existing date+time combinations', async () => {
|
||||
@@ -97,7 +105,7 @@ describe('SlotGeneratorService', () => {
|
||||
})
|
||||
|
||||
it('sets source to TEMPLATE for all generated slots', async () => {
|
||||
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 13 })
|
||||
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 12 })
|
||||
|
||||
await service.generateSlots(1)
|
||||
|
||||
@@ -110,6 +118,78 @@ describe('SlotGeneratorService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('skips dates that already have any TimeSlot row (admin-touched)', async () => {
|
||||
const defaultSlots = getDefaultTimeSlots()
|
||||
const tomorrow = new Date()
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
tomorrow.setUTCHours(0, 0, 0, 0)
|
||||
|
||||
// Pre-mark day 0 and day 2 as touched (any status).
|
||||
const touchedDay0 = new Date(tomorrow)
|
||||
const touchedDay2 = new Date(tomorrow)
|
||||
touchedDay2.setDate(touchedDay2.getDate() + 2)
|
||||
|
||||
mockPrisma.timeSlot.findMany.mockResolvedValueOnce([
|
||||
{ date: touchedDay0 },
|
||||
{ date: touchedDay2 },
|
||||
])
|
||||
// createMany mock reports whatever it would actually insert.
|
||||
// With 1 day not touched (13 default slots), that's defaultSlots.length.
|
||||
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: defaultSlots.length })
|
||||
|
||||
const count = await service.generateSlots(3)
|
||||
|
||||
// Day 1 should be the only day generated (3 - 2 touched = 1).
|
||||
expect(count).toBe(defaultSlots.length)
|
||||
const { data } = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
|
||||
data: Array<{ date: Date }>
|
||||
}
|
||||
expect(data).toHaveLength(defaultSlots.length)
|
||||
|
||||
// All generated dates must be UTC midnights within the requested window.
|
||||
const rangeEnd = new Date(tomorrow)
|
||||
rangeEnd.setDate(rangeEnd.getDate() + 2)
|
||||
rangeEnd.setUTCHours(23, 59, 59, 999)
|
||||
for (const row of data) {
|
||||
expect(row.date.getTime()).toBeGreaterThanOrEqual(tomorrow.getTime())
|
||||
expect(row.date.getTime()).toBeLessThanOrEqual(rangeEnd.getTime())
|
||||
}
|
||||
|
||||
// The two touched dates must not appear in the generated batch.
|
||||
const generatedKeys = new Set(data.map((r) => r.date.toISOString().slice(0, 10)))
|
||||
expect(generatedKeys.has(touchedDay0.toISOString().slice(0, 10))).toBe(false)
|
||||
expect(generatedKeys.has(touchedDay2.toISOString().slice(0, 10))).toBe(false)
|
||||
})
|
||||
|
||||
it('returns 0 and skips createMany when every date in the window is already touched', async () => {
|
||||
const tomorrow = new Date()
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
tomorrow.setUTCHours(0, 0, 0, 0)
|
||||
|
||||
const allDays = [0, 1, 2].map((offset) => {
|
||||
const d = new Date(tomorrow)
|
||||
d.setDate(d.getDate() + offset)
|
||||
return { date: d }
|
||||
})
|
||||
mockPrisma.timeSlot.findMany.mockResolvedValueOnce(allDays)
|
||||
|
||||
const count = await service.generateSlots(3)
|
||||
|
||||
expect(count).toBe(0)
|
||||
expect(mockPrisma.timeSlot.createMany).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still uses skipDuplicates on createMany when it does run', async () => {
|
||||
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 13 })
|
||||
|
||||
await service.generateSlots(1)
|
||||
|
||||
const call = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
|
||||
skipDuplicates: boolean
|
||||
}
|
||||
expect(call.skipDuplicates).toBe(true)
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// cleanupExpiredSlots
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -148,6 +228,53 @@ describe('SlotGeneratorService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// prunePastClosedSlots
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe('prunePastClosedSlots', () => {
|
||||
it('deletes past CLOSED slots that have no bookings', async () => {
|
||||
mockPrisma.timeSlot.deleteMany.mockResolvedValueOnce({ count: 4 })
|
||||
|
||||
const count = await service.prunePastClosedSlots()
|
||||
|
||||
expect(count).toBe(4)
|
||||
expect(mockPrisma.timeSlot.deleteMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
status: TimeSlotStatus.CLOSED,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('only deletes slots with date strictly before today', async () => {
|
||||
mockPrisma.timeSlot.deleteMany.mockResolvedValueOnce({ count: 0 })
|
||||
|
||||
await service.prunePastClosedSlots()
|
||||
|
||||
const where = (mockPrisma.timeSlot.deleteMany.mock.calls[0][0] as {
|
||||
where: { date: { lt: Date } }
|
||||
}).where
|
||||
const today = new Date()
|
||||
today.setUTCHours(0, 0, 0, 0)
|
||||
|
||||
const diff = Math.abs(where.date.lt.getTime() - today.getTime())
|
||||
expect(diff).toBeLessThan(1000)
|
||||
})
|
||||
|
||||
it('keeps CLOSED slots that still have bookings attached', async () => {
|
||||
mockPrisma.timeSlot.deleteMany.mockResolvedValueOnce({ count: 0 })
|
||||
|
||||
await service.prunePastClosedSlots()
|
||||
|
||||
const where = (mockPrisma.timeSlot.deleteMany.mock.calls[0][0] as {
|
||||
where: { bookings: { none: Record<string, never> } }
|
||||
}).where
|
||||
expect(where.bookings).toEqual({ none: {} })
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// checkExpiredMemberships
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
TimeSlotSource,
|
||||
BookingStatus,
|
||||
DEFAULT_SLOT_CAPACITY,
|
||||
getDefaultTimeSlots,
|
||||
} from '@mp-pilates/shared'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -39,6 +40,8 @@ const mockPrisma = {
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
createMany: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
weekTemplate: {
|
||||
findMany: jest.fn(),
|
||||
@@ -48,6 +51,27 @@ const mockPrisma = {
|
||||
$transaction: jest.fn(),
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward $transaction's callback to a tx object that shares the timeSlot
|
||||
* mock implementations, so transactional calls are observable the same way
|
||||
* as top-level ones.
|
||||
*/
|
||||
function bindTransaction() {
|
||||
const mockTx = {
|
||||
timeSlot: {
|
||||
findMany: mockPrisma.timeSlot.findMany,
|
||||
findUnique: mockPrisma.timeSlot.findUnique,
|
||||
create: mockPrisma.timeSlot.create,
|
||||
update: mockPrisma.timeSlot.update,
|
||||
createMany: mockPrisma.timeSlot.createMany,
|
||||
delete: mockPrisma.timeSlot.delete,
|
||||
},
|
||||
}
|
||||
mockPrisma.$transaction.mockImplementation(
|
||||
async (cb: (tx: typeof mockTx) => unknown) => cb(mockTx),
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -57,6 +81,7 @@ describe('TimeSlotService', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks()
|
||||
bindTransaction()
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
@@ -207,6 +232,69 @@ describe('TimeSlotService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// getSchedulePreview
|
||||
// -------------------------------------------------------------------------
|
||||
describe('getSchedulePreview', () => {
|
||||
const date = '2026-04-07'
|
||||
|
||||
it('returns OPEN/FULL rows with isPublished: true', async () => {
|
||||
mockPrisma.timeSlot.findMany.mockResolvedValueOnce([
|
||||
makeSlot({ id: 'slot-1', startTime: '09:00', endTime: '10:00', status: TimeSlotStatus.OPEN }),
|
||||
makeSlot({ id: 'slot-2', startTime: '10:30', endTime: '11:30', status: TimeSlotStatus.FULL }),
|
||||
])
|
||||
|
||||
const result = await service.getSchedulePreview(date)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result.every((s) => s.isPublished === true)).toBe(true)
|
||||
})
|
||||
|
||||
it('HIDES CLOSED rows so the legacy client does not render them as "已发布"', async () => {
|
||||
// Simulates an admin "clear day" — DB now has 13 CLOSED rows.
|
||||
mockPrisma.timeSlot.findMany.mockResolvedValueOnce(
|
||||
Array.from({ length: 13 }, (_, i) =>
|
||||
makeSlot({
|
||||
id: `slot-${i}`,
|
||||
startTime: `${String(8 + Math.floor(i / 2)).padStart(2, '0')}:${i % 2 === 0 ? '00' : '30'}`,
|
||||
endTime: `${String(8 + Math.floor(i / 2)).padStart(2, '0')}:${i % 2 === 0 ? '30' : '30'}`,
|
||||
status: TimeSlotStatus.CLOSED,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const result = await service.getSchedulePreview(date)
|
||||
|
||||
// Critical for online compatibility: the legacy client must see []
|
||||
// so it shows "当日暂无排课", not 13 phantom "已发布" slots.
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('returns the OPEN subset and hides CLOSED when mixed', async () => {
|
||||
mockPrisma.timeSlot.findMany.mockResolvedValueOnce([
|
||||
makeSlot({ id: 'open-1', status: TimeSlotStatus.OPEN }),
|
||||
makeSlot({ id: 'closed-1', status: TimeSlotStatus.CLOSED, startTime: '10:00', endTime: '11:00' }),
|
||||
makeSlot({ id: 'open-2', status: TimeSlotStatus.OPEN, startTime: '10:30', endTime: '11:30' }),
|
||||
])
|
||||
|
||||
const result = await service.getSchedulePreview(date)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result.map((s) => s.id).sort()).toEqual(['open-1', 'open-2'])
|
||||
expect(result.find((s) => s.id === 'closed-1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns the ghost template when the day has no rows at all', async () => {
|
||||
mockPrisma.timeSlot.findMany.mockResolvedValueOnce([])
|
||||
|
||||
const result = await service.getSchedulePreview(date)
|
||||
|
||||
// Ghost = isPublished false, id null.
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
expect(result.every((s) => s.isPublished === false && s.id === null)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// createManualSlot
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -270,4 +358,269 @@ describe('TimeSlotService', () => {
|
||||
await expect(service.closeSlot('ghost')).rejects.toThrow(NotFoundException)
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// publishDaySlots — admin schedule management
|
||||
// -------------------------------------------------------------------------
|
||||
describe('publishDaySlots', () => {
|
||||
const date = '2026-04-07'
|
||||
|
||||
it('materializes the default template when the day has no rows yet', async () => {
|
||||
// First findMany (existing check) → empty; second findMany (re-read) → materialized rows.
|
||||
const materialized = [
|
||||
makeSlot({ id: 'slot-A', startTime: '08:00', endTime: '09:00', source: TimeSlotSource.TEMPLATE }),
|
||||
makeSlot({ id: 'slot-B', startTime: '09:30', endTime: '10:30', source: TimeSlotSource.TEMPLATE }),
|
||||
]
|
||||
mockPrisma.timeSlot.findMany
|
||||
.mockResolvedValueOnce([]) // initial check
|
||||
.mockResolvedValueOnce(materialized) // after materialize
|
||||
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 2 })
|
||||
mockPrisma.timeSlot.findMany.mockResolvedValueOnce(materialized) // final state
|
||||
|
||||
await service.publishDaySlots({ date, slots: [] })
|
||||
|
||||
expect(mockPrisma.timeSlot.createMany).toHaveBeenCalledTimes(1)
|
||||
const createCall = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
|
||||
data: Array<{ source: string; status: string; date: Date }>
|
||||
skipDuplicates: boolean
|
||||
}
|
||||
expect(createCall.skipDuplicates).toBe(true)
|
||||
for (const row of createCall.data) {
|
||||
expect(row.source).toBe(TimeSlotSource.TEMPLATE)
|
||||
expect(row.status).toBe(TimeSlotStatus.OPEN)
|
||||
}
|
||||
})
|
||||
|
||||
it('CLOSEs (never deletes) orphaned rows when admin publishes empty list', async () => {
|
||||
const existing = [
|
||||
makeSlot({ id: 'slot-1', bookedCount: 0 }),
|
||||
makeSlot({ id: 'slot-2', bookedCount: 0, startTime: '09:30', endTime: '10:30' }),
|
||||
]
|
||||
// Initial check + final-state read both return the (now-CLOSED) rows.
|
||||
mockPrisma.timeSlot.findMany
|
||||
.mockResolvedValueOnce(existing)
|
||||
.mockResolvedValueOnce(existing)
|
||||
mockPrisma.timeSlot.update.mockResolvedValue({})
|
||||
mockPrisma.timeSlot.delete = jest.fn() // safety: ensure delete is never used here
|
||||
|
||||
await service.publishDaySlots({ date, slots: [] })
|
||||
|
||||
expect(mockPrisma.timeSlot.delete).not.toHaveBeenCalled()
|
||||
// Each orphan is updated to CLOSED (no bookedCount discrimination).
|
||||
const updateCalls = mockPrisma.timeSlot.update.mock.calls
|
||||
expect(updateCalls.length).toBe(2)
|
||||
for (const call of updateCalls) {
|
||||
const data = (call[0] as { data: { status: string } }).data
|
||||
expect(data.status).toBe(TimeSlotStatus.CLOSED)
|
||||
}
|
||||
})
|
||||
|
||||
it('CLOSEs (never deletes) orphaned rows even when they have bookings', async () => {
|
||||
const existing = [
|
||||
makeSlot({ id: 'slot-1', bookedCount: 3 }), // has bookings
|
||||
]
|
||||
mockPrisma.timeSlot.findMany
|
||||
.mockResolvedValueOnce(existing)
|
||||
.mockResolvedValueOnce(existing)
|
||||
mockPrisma.timeSlot.update.mockResolvedValue({})
|
||||
mockPrisma.timeSlot.delete = jest.fn()
|
||||
|
||||
await service.publishDaySlots({ date, slots: [] })
|
||||
|
||||
expect(mockPrisma.timeSlot.delete).not.toHaveBeenCalled()
|
||||
const updateCall = mockPrisma.timeSlot.update.mock.calls[0]
|
||||
const data = (updateCall[0] as { data: { status: string } }).data
|
||||
expect(data.status).toBe(TimeSlotStatus.CLOSED)
|
||||
})
|
||||
|
||||
it('updates existing slot referenced by existingSlotId and keeps its capacity >= bookedCount', async () => {
|
||||
const existing = [
|
||||
makeSlot({ id: 'slot-1', bookedCount: 3, capacity: 5 }),
|
||||
]
|
||||
mockPrisma.timeSlot.findMany
|
||||
.mockResolvedValueOnce(existing)
|
||||
.mockResolvedValueOnce(existing)
|
||||
const updated = makeSlot({ id: 'slot-1', bookedCount: 3, capacity: 4 })
|
||||
mockPrisma.timeSlot.update.mockResolvedValueOnce(updated)
|
||||
|
||||
await service.publishDaySlots({
|
||||
date,
|
||||
slots: [{ existingSlotId: 'slot-1', startTime: '09:00', endTime: '10:00', capacity: 2 }],
|
||||
})
|
||||
|
||||
const updateData = (mockPrisma.timeSlot.update.mock.calls[0][0] as {
|
||||
where: { id: string }
|
||||
data: { capacity: number; status?: string }
|
||||
})
|
||||
expect(updateData.where.id).toBe('slot-1')
|
||||
// capacity clamped up to bookedCount (3), not the requested 2.
|
||||
expect(updateData.data.capacity).toBe(3)
|
||||
// status is NOT touched on update — existing CLOSED rows stay CLOSED
|
||||
// so that re-publishing the day cannot silently undo "rest day" intent.
|
||||
expect(updateData.data.status).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves CLOSED status when admin re-publishes the day unchanged', async () => {
|
||||
// Scenario: admin cleared the day, then reloaded the page. The page
|
||||
// re-sends all CLOSED rows via existingSlotId. They must remain CLOSED.
|
||||
const existing = [
|
||||
makeSlot({ id: 'slot-1', status: TimeSlotStatus.CLOSED, bookedCount: 0 }),
|
||||
]
|
||||
mockPrisma.timeSlot.findMany
|
||||
.mockResolvedValueOnce(existing)
|
||||
.mockResolvedValueOnce(existing)
|
||||
mockPrisma.timeSlot.update.mockResolvedValueOnce(existing[0])
|
||||
|
||||
await service.publishDaySlots({
|
||||
date,
|
||||
slots: [{ existingSlotId: 'slot-1', startTime: '09:00', endTime: '10:00', capacity: 1 }],
|
||||
})
|
||||
|
||||
const updateData = (mockPrisma.timeSlot.update.mock.calls[0][0] as {
|
||||
data: { status?: string }
|
||||
})
|
||||
expect(updateData.data.status).toBeUndefined()
|
||||
})
|
||||
|
||||
it('creates new slots for entries without an existingSlotId', async () => {
|
||||
const existing: typeof makeSlot extends (...a: any) => infer R ? R : never = [] as never
|
||||
mockPrisma.timeSlot.findMany
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([])
|
||||
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 0 })
|
||||
mockPrisma.timeSlot.create.mockResolvedValueOnce(
|
||||
makeSlot({ id: 'slot-new', source: TimeSlotSource.MANUAL, startTime: '14:00', endTime: '15:00' }),
|
||||
)
|
||||
mockPrisma.timeSlot.findMany.mockResolvedValueOnce([
|
||||
makeSlot({ id: 'slot-new', source: TimeSlotSource.MANUAL, startTime: '14:00', endTime: '15:00' }),
|
||||
])
|
||||
|
||||
await service.publishDaySlots({
|
||||
date,
|
||||
slots: [{ startTime: '14:00', endTime: '15:00', capacity: 4 }],
|
||||
})
|
||||
|
||||
expect(mockPrisma.timeSlot.create).toHaveBeenCalledTimes(1)
|
||||
const createData = (mockPrisma.timeSlot.create.mock.calls[0][0] as {
|
||||
data: { source: string; status: string; capacity: number }
|
||||
}).data
|
||||
expect(createData.source).toBe(TimeSlotSource.MANUAL)
|
||||
expect(createData.status).toBe(TimeSlotStatus.OPEN)
|
||||
expect(createData.capacity).toBe(4)
|
||||
})
|
||||
|
||||
it('updates materialized template rows when ghost preview is published without ids', async () => {
|
||||
const defaultSlots = getDefaultTimeSlots()
|
||||
const materialized = defaultSlots.map((slot, index) =>
|
||||
makeSlot({
|
||||
id: `slot-${index}`,
|
||||
startTime: slot.startTime,
|
||||
endTime: slot.endTime,
|
||||
source: TimeSlotSource.TEMPLATE,
|
||||
}),
|
||||
)
|
||||
mockPrisma.timeSlot.findMany
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce(materialized)
|
||||
.mockResolvedValueOnce(materialized)
|
||||
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: defaultSlots.length })
|
||||
mockPrisma.timeSlot.update.mockImplementation(async (args: { where: { id: string } }) => {
|
||||
const row = materialized.find((s) => s.id === args.where.id)!
|
||||
return { ...row }
|
||||
})
|
||||
|
||||
await service.publishDaySlots({
|
||||
date,
|
||||
slots: defaultSlots.map((slot) => ({
|
||||
startTime: slot.startTime,
|
||||
endTime: slot.endTime,
|
||||
capacity: DEFAULT_SLOT_CAPACITY,
|
||||
})),
|
||||
})
|
||||
|
||||
expect(mockPrisma.timeSlot.createMany).toHaveBeenCalledTimes(1)
|
||||
expect(mockPrisma.timeSlot.create).not.toHaveBeenCalled()
|
||||
expect(mockPrisma.timeSlot.update).toHaveBeenCalledTimes(defaultSlots.length)
|
||||
})
|
||||
|
||||
it('reopens an existing row by time when admin re-adds a slot without existingSlotId', async () => {
|
||||
const existing = [
|
||||
makeSlot({
|
||||
id: 'slot-open',
|
||||
startTime: '09:30',
|
||||
endTime: '10:30',
|
||||
status: TimeSlotStatus.OPEN,
|
||||
}),
|
||||
]
|
||||
mockPrisma.timeSlot.findMany
|
||||
.mockResolvedValueOnce(existing)
|
||||
.mockResolvedValueOnce(existing)
|
||||
mockPrisma.timeSlot.update.mockResolvedValueOnce({
|
||||
...existing[0],
|
||||
status: TimeSlotStatus.OPEN,
|
||||
source: TimeSlotSource.MANUAL,
|
||||
capacity: 3,
|
||||
})
|
||||
|
||||
await service.publishDaySlots({
|
||||
date,
|
||||
slots: [{ startTime: '09:30', endTime: '10:30', capacity: 3 }],
|
||||
})
|
||||
|
||||
expect(mockPrisma.timeSlot.create).not.toHaveBeenCalled()
|
||||
const updateData = (mockPrisma.timeSlot.update.mock.calls[0][0] as {
|
||||
data: { status: string; source: string; capacity: number }
|
||||
}).data
|
||||
expect(updateData.status).toBe(TimeSlotStatus.OPEN)
|
||||
expect(updateData.source).toBe(TimeSlotSource.MANUAL)
|
||||
expect(updateData.capacity).toBe(3)
|
||||
})
|
||||
|
||||
it('reopens a CLOSED row when admin publishes the same time window as a new slot', async () => {
|
||||
const existing = [
|
||||
makeSlot({
|
||||
id: 'slot-closed',
|
||||
startTime: '11:00',
|
||||
endTime: '12:00',
|
||||
status: TimeSlotStatus.CLOSED,
|
||||
}),
|
||||
]
|
||||
mockPrisma.timeSlot.findMany
|
||||
.mockResolvedValueOnce(existing)
|
||||
.mockResolvedValueOnce(existing)
|
||||
mockPrisma.timeSlot.update.mockResolvedValueOnce({
|
||||
...existing[0],
|
||||
status: TimeSlotStatus.OPEN,
|
||||
})
|
||||
|
||||
await service.publishDaySlots({
|
||||
date,
|
||||
slots: [{ startTime: '11:00', endTime: '12:00', capacity: 2 }],
|
||||
})
|
||||
|
||||
expect(mockPrisma.timeSlot.create).not.toHaveBeenCalled()
|
||||
const updateData = (mockPrisma.timeSlot.update.mock.calls[0][0] as {
|
||||
data: { status: string }
|
||||
}).data
|
||||
expect(updateData.status).toBe(TimeSlotStatus.OPEN)
|
||||
})
|
||||
|
||||
it('returns the final state of the day ordered by startTime', async () => {
|
||||
const finalState = [
|
||||
makeSlot({ id: 'a', startTime: '09:00', endTime: '10:00' }),
|
||||
makeSlot({ id: 'b', startTime: '10:30', endTime: '11:30' }),
|
||||
]
|
||||
mockPrisma.timeSlot.findMany
|
||||
.mockResolvedValueOnce([]) // initial check
|
||||
.mockResolvedValueOnce([]) // after materialize
|
||||
.mockResolvedValueOnce(finalState) // final state
|
||||
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 0 })
|
||||
|
||||
const result = await service.publishDaySlots({ date, slots: [] })
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].startTime).toBe('09:00')
|
||||
expect(result[1].startTime).toBe('10:30')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,6 +17,18 @@ function toUtcMidnight(date: Date): Date {
|
||||
return d
|
||||
}
|
||||
|
||||
/** Add `days` whole days to a UTC date, returning a new Date. */
|
||||
function addDays(date: Date, days: number): Date {
|
||||
const d = new Date(date)
|
||||
d.setUTCDate(d.getUTCDate() + days)
|
||||
return d
|
||||
}
|
||||
|
||||
/** Normalise a Date (UTC midnight) to its `YYYY-MM-DD` key for set membership. */
|
||||
function toDateKey(date: Date): string {
|
||||
return date.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SlotGeneratorService {
|
||||
private readonly logger = new Logger(SlotGeneratorService.name)
|
||||
@@ -25,8 +37,14 @@ export class SlotGeneratorService {
|
||||
|
||||
/**
|
||||
* Generate time slots for the next `daysAhead` days based on the fixed
|
||||
* default schedule (Mon-Sun: 08:00-09:00, then 09:30-21:30 hourly).
|
||||
* Uses `createMany` with `skipDuplicates` so re-runs are safe.
|
||||
* default schedule (morning on the hour 08:00–12:00, afternoon from 13:30 hourly to 21:30).
|
||||
*
|
||||
* Behaviour:
|
||||
* - Days that already have any TimeSlot row (any status, including CLOSED)
|
||||
* are treated as "admin-touched" and skipped, so that an explicit "rest
|
||||
* day" intent cannot be silently overwritten by the nightly cron.
|
||||
* - The final `createMany` call still uses `skipDuplicates` so that
|
||||
* re-runs against partially-existing dates remain safe.
|
||||
*
|
||||
* @returns Number of newly created slots
|
||||
*/
|
||||
@@ -37,6 +55,18 @@ export class SlotGeneratorService {
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
tomorrow.setUTCHours(0, 0, 0, 0)
|
||||
|
||||
const rangeStart = toUtcMidnight(tomorrow)
|
||||
const rangeEnd = toUtcMidnight(addDays(tomorrow, daysAhead - 1))
|
||||
|
||||
// Pre-fetch dates inside the window that already have at least one row.
|
||||
// Any status (OPEN / FULL / CLOSED) counts as "admin touched this day".
|
||||
const touchedRows = await this.prisma.timeSlot.findMany({
|
||||
where: { date: { gte: rangeStart, lte: rangeEnd } },
|
||||
select: { date: true },
|
||||
distinct: ['date'],
|
||||
})
|
||||
const touchedKeys = new Set(touchedRows.map((r) => toDateKey(r.date)))
|
||||
|
||||
const slotsToCreate: Array<{
|
||||
date: Date
|
||||
startTime: string
|
||||
@@ -46,12 +76,15 @@ export class SlotGeneratorService {
|
||||
}> = []
|
||||
|
||||
for (let offset = 0; offset < daysAhead; offset++) {
|
||||
const target = new Date(tomorrow)
|
||||
target.setDate(target.getDate() + offset)
|
||||
const target = toUtcMidnight(addDays(tomorrow, offset))
|
||||
|
||||
if (touchedKeys.has(toDateKey(target))) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const slot of defaultSlots) {
|
||||
slotsToCreate.push({
|
||||
date: toUtcMidnight(target),
|
||||
date: target,
|
||||
startTime: slot.startTime,
|
||||
endTime: slot.endTime,
|
||||
capacity: DEFAULT_SLOT_CAPACITY,
|
||||
@@ -61,6 +94,9 @@ export class SlotGeneratorService {
|
||||
}
|
||||
|
||||
if (slotsToCreate.length === 0) {
|
||||
this.logger.log(
|
||||
`Skipped ${touchedKeys.size} admin-touched date(s); nothing to generate`,
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -69,7 +105,9 @@ export class SlotGeneratorService {
|
||||
skipDuplicates: true,
|
||||
})
|
||||
|
||||
this.logger.log(`Generated ${result.count} new time slots`)
|
||||
this.logger.log(
|
||||
`Generated ${result.count} new time slots (skipped ${touchedKeys.size} admin-touched date(s))`,
|
||||
)
|
||||
return result.count
|
||||
}
|
||||
|
||||
@@ -94,6 +132,34 @@ export class SlotGeneratorService {
|
||||
return result.count
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete past TimeSlot rows that are CLOSED and have no bookings attached.
|
||||
*
|
||||
* Without this, every admin "clear" leaves 13 CLOSED/TEMPLATE rows in the
|
||||
* DB forever (no member ever queries past dates, but they pile up).
|
||||
* Rows with `bookedCount > 0` or any Booking record are kept so we never
|
||||
* break the Booking.timeSlotId foreign key.
|
||||
*
|
||||
* Runs nightly alongside cleanupExpiredSlots.
|
||||
*
|
||||
* @returns Number of slots deleted
|
||||
*/
|
||||
async prunePastClosedSlots(): Promise<number> {
|
||||
const today = new Date()
|
||||
today.setUTCHours(0, 0, 0, 0)
|
||||
|
||||
const result = await this.prisma.timeSlot.deleteMany({
|
||||
where: {
|
||||
status: TimeSlotStatus.CLOSED,
|
||||
date: { lt: today },
|
||||
bookings: { none: {} },
|
||||
},
|
||||
})
|
||||
|
||||
this.logger.log(`Pruned ${result.count} past closed time slots`)
|
||||
return result.count
|
||||
}
|
||||
|
||||
/**
|
||||
* Expire memberships whose end date has passed or whose remaining sessions
|
||||
* have been exhausted.
|
||||
|
||||
@@ -18,6 +18,10 @@ export class TimeSlotService {
|
||||
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 23, 59, 59, 999))
|
||||
}
|
||||
|
||||
private slotTimeKey(startTime: string, endTime: string): string {
|
||||
return `${startTime}\0${endTime}`
|
||||
}
|
||||
|
||||
private mapToWithBookingStatus(
|
||||
slot: {
|
||||
id: string
|
||||
@@ -148,13 +152,26 @@ export class TimeSlotService {
|
||||
|
||||
/**
|
||||
* Return a schedule preview for a given date.
|
||||
* If TimeSlot records already exist → return them (isPublished: true).
|
||||
* Otherwise → derive from the fixed default schedule (isPublished: false).
|
||||
*
|
||||
* Visibility rules (driven by the online mini-program's existing client):
|
||||
* - OPEN/FULL slots are returned with `isPublished: true`.
|
||||
* - CLOSED slots are HIDDEN from preview entirely. The pre-fix admin flow
|
||||
* used to DELETE these rows on "clear day", but a non-deleting flow
|
||||
* leaves CLOSED rows behind — the existing mini-program UI does not
|
||||
* understand the CLOSED status and would render them as "已发布",
|
||||
* making it look like the clear did not take effect. Hiding them
|
||||
* preserves the legacy "empty list = rest day" UX until the client
|
||||
* is republished with CLOSED-aware rendering.
|
||||
* - When the day is entirely CLOSED (a "rest day"), return an empty
|
||||
* array rather than the default ghost template, so the client shows
|
||||
* "当日暂无排课" instead of inviting another publish-loop cycle.
|
||||
* - When the day has no rows at all (never generated), return the
|
||||
* default template as ghost previews (`isPublished: false`).
|
||||
*/
|
||||
async getSchedulePreview(date: string): Promise<ScheduleSlotPreview[]> {
|
||||
const parsedDate = new Date(date)
|
||||
|
||||
// 1. Check for existing TimeSlot records (all statuses)
|
||||
// 1. Check for existing TimeSlot records (all statuses).
|
||||
const existingSlots = await this.prisma.timeSlot.findMany({
|
||||
where: {
|
||||
date: { gte: this.toDateOfDay(parsedDate), lte: this.toEndOfDay(parsedDate) },
|
||||
@@ -162,8 +179,14 @@ export class TimeSlotService {
|
||||
orderBy: { startTime: 'asc' },
|
||||
})
|
||||
|
||||
if (existingSlots.length > 0) {
|
||||
return existingSlots.map((slot) => ({
|
||||
// 2. Filter out CLOSED — they are admin-closed (typically "rest day")
|
||||
// and must not surface to the legacy mini-program client.
|
||||
const openSlots = existingSlots.filter(
|
||||
(slot) => slot.status !== TimeSlotStatus.CLOSED,
|
||||
)
|
||||
|
||||
if (openSlots.length > 0) {
|
||||
return openSlots.map((slot) => ({
|
||||
id: slot.id,
|
||||
date: date,
|
||||
startTime: slot.startTime,
|
||||
@@ -177,6 +200,12 @@ export class TimeSlotService {
|
||||
}))
|
||||
}
|
||||
|
||||
// 3. All rows for the day are CLOSED → admin declared a rest day.
|
||||
// Return [] so the client shows the "no slots" empty state.
|
||||
if (existingSlots.length > 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// 2. No existing slots — use fixed default schedule
|
||||
const defaultSlots = getDefaultTimeSlots()
|
||||
|
||||
@@ -195,20 +224,65 @@ export class TimeSlotService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish (create/update/remove) time slots for a specific date.
|
||||
* - Slots with existingSlotId → update
|
||||
* - New slots → create
|
||||
* - Existing DB slots not referenced → delete (or CLOSE if they have bookings)
|
||||
* Publish (create/update/close) time slots for a specific date.
|
||||
*
|
||||
* Behavior:
|
||||
* - If the day has no TimeSlot rows yet (e.g. admin opened a future date
|
||||
* that the cron has not generated), materialize the default template
|
||||
* first so that any subsequent close intent persists.
|
||||
* - Slots referenced via existingSlotId → updated in place; existing
|
||||
* status (OPEN / CLOSED) is preserved. Reopening a CLOSED slot is
|
||||
* intentionally NOT possible through this endpoint — the front-end
|
||||
* re-sends unchanged rows on every publish, so an explicit reopen
|
||||
* would silently undo "rest day" intent. Use the dedicated reopen
|
||||
* endpoint (TODO) or delete + re-add via the UI instead.
|
||||
* - Slots with no existingSlotId → created as MANUAL/OPEN.
|
||||
* - Existing rows that the request drops are set to CLOSED rather than
|
||||
* deleted, so that the admin's intent ("this is a rest day") survives
|
||||
* the nightly slot-generation cron (skipDuplicates / touched-date skip).
|
||||
* - Materialize + orphan-close interaction: when the day has no rows yet,
|
||||
* every default template slot is written first as OPEN/TEMPLATE, then
|
||||
* any slot not referenced by the request is closed. So if an admin
|
||||
* edits a ghost preview and publishes only a subset, the unused default
|
||||
* slots end up CLOSED/TEMPLATE rather than OPEN/TEMPLATE.
|
||||
*
|
||||
* @returns Final TimeSlot rows for the day, ordered by startTime.
|
||||
*/
|
||||
async publishDaySlots(dto: PublishDaySlotsDto) {
|
||||
const parsedDate = new Date(dto.date + 'T00:00:00Z')
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
// 1. Get existing slots for this date
|
||||
const existing = await tx.timeSlot.findMany({
|
||||
where: { date: { gte: this.toDateOfDay(parsedDate), lte: this.toEndOfDay(parsedDate) } },
|
||||
const dayRange = {
|
||||
date: { gte: this.toDateOfDay(parsedDate), lte: this.toEndOfDay(parsedDate) },
|
||||
}
|
||||
|
||||
// 1. Look up the existing rows for the day.
|
||||
let existing = await tx.timeSlot.findMany({ where: dayRange })
|
||||
|
||||
// 2. Materialize the default template if the admin is touching a day
|
||||
// that has never been generated. This records their intent (the row
|
||||
// now exists), and any empty publish will leave the day in a
|
||||
// CLOSED-everything state that the nightly cron will not undo.
|
||||
if (existing.length === 0) {
|
||||
const defaultSlots = getDefaultTimeSlots()
|
||||
await tx.timeSlot.createMany({
|
||||
data: defaultSlots.map((slot) => ({
|
||||
date: this.toDateOfDay(parsedDate),
|
||||
startTime: slot.startTime,
|
||||
endTime: slot.endTime,
|
||||
capacity: DEFAULT_SLOT_CAPACITY,
|
||||
source: TimeSlotSource.TEMPLATE,
|
||||
status: TimeSlotStatus.OPEN,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
})
|
||||
existing = await tx.timeSlot.findMany({ where: dayRange })
|
||||
}
|
||||
|
||||
const existingMap = new Map(existing.map((s) => [s.id, s]))
|
||||
const existingByTime = new Map(
|
||||
existing.map((s) => [this.slotTimeKey(s.startTime, s.endTime), s]),
|
||||
)
|
||||
const keptIds = new Set<string>()
|
||||
|
||||
const results: Array<{
|
||||
@@ -222,28 +296,46 @@ export class TimeSlotService {
|
||||
source: string
|
||||
}> = []
|
||||
|
||||
// 2. Process each slot in the request
|
||||
// 3. Process each slot in the request.
|
||||
for (const item of dto.slots) {
|
||||
let resolvedId: string | null = null
|
||||
if (item.existingSlotId && existingMap.has(item.existingSlotId)) {
|
||||
// Update existing slot
|
||||
const existingSlot = existingMap.get(item.existingSlotId)!
|
||||
resolvedId = item.existingSlotId
|
||||
} else {
|
||||
const byTime = existingByTime.get(this.slotTimeKey(item.startTime, item.endTime))
|
||||
if (byTime) {
|
||||
resolvedId = byTime.id
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedId) {
|
||||
const existingSlot = existingMap.get(resolvedId)!
|
||||
const safeCapacity = Math.max(item.capacity, existingSlot.bookedCount)
|
||||
const matchedByExplicitId =
|
||||
Boolean(item.existingSlotId) && item.existingSlotId === resolvedId
|
||||
|
||||
const updated = await tx.timeSlot.update({
|
||||
where: { id: item.existingSlotId },
|
||||
where: { id: resolvedId },
|
||||
data: {
|
||||
startTime: item.startTime,
|
||||
endTime: item.endTime,
|
||||
capacity: safeCapacity,
|
||||
// Rows referenced by id keep status (CLOSED stays CLOSED on re-publish).
|
||||
// Rows matched only by time (ghost preview, re-added slot) are published OPEN.
|
||||
...(!matchedByExplicitId
|
||||
? {
|
||||
status: TimeSlotStatus.OPEN,
|
||||
source: TimeSlotSource.MANUAL,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
})
|
||||
keptIds.add(item.existingSlotId)
|
||||
keptIds.add(resolvedId)
|
||||
results.push(updated)
|
||||
} else {
|
||||
// Create new slot
|
||||
const created = await tx.timeSlot.create({
|
||||
data: {
|
||||
date: parsedDate,
|
||||
date: this.toDateOfDay(parsedDate),
|
||||
startTime: item.startTime,
|
||||
endTime: item.endTime,
|
||||
capacity: item.capacity,
|
||||
@@ -251,26 +343,34 @@ export class TimeSlotService {
|
||||
status: TimeSlotStatus.OPEN,
|
||||
},
|
||||
})
|
||||
keptIds.add(created.id)
|
||||
existingMap.set(created.id, created)
|
||||
existingByTime.set(this.slotTimeKey(created.startTime, created.endTime), created)
|
||||
results.push(created)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Handle orphaned existing slots (not in request)
|
||||
// 4. Close orphaned existing rows. We never delete — keeping the row
|
||||
// preserves the admin's "rest day" intent across cron runs.
|
||||
for (const slot of existing) {
|
||||
if (!keptIds.has(slot.id)) {
|
||||
if (slot.bookedCount > 0) {
|
||||
// Has bookings → close instead of delete
|
||||
if (slot.status !== TimeSlotStatus.CLOSED) {
|
||||
await tx.timeSlot.update({
|
||||
where: { id: slot.id },
|
||||
data: { status: TimeSlotStatus.CLOSED },
|
||||
})
|
||||
} else {
|
||||
await tx.timeSlot.delete({ where: { id: slot.id } })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results.map((slot) => ({
|
||||
// 5. Return the final state of the day so the admin UI can refresh
|
||||
// without a second round-trip.
|
||||
const finalState = await tx.timeSlot.findMany({
|
||||
where: dayRange,
|
||||
orderBy: { startTime: 'asc' },
|
||||
})
|
||||
|
||||
return finalState.map((slot) => ({
|
||||
id: slot.id,
|
||||
date: slot.date.toISOString().split('T')[0],
|
||||
startTime: slot.startTime,
|
||||
|
||||
@@ -15,11 +15,15 @@ export const DEFAULT_SLOT_CAPACITY = 1
|
||||
/** 自动生成时段的天数范围 */
|
||||
export const SLOT_GENERATION_DAYS = 14
|
||||
|
||||
/** 默认排课时间表:第一节 08:00-09:00,之后从 09:30 起每小时一节至 21:30 */
|
||||
/** 默认排课:上午整点 08:00–12:00(8–9、9–10、10–11、11–12),下午 13:30 起每小时至 21:30 结束 */
|
||||
export const DEFAULT_SCHEDULE_START_TIME = '08:00'
|
||||
export const DEFAULT_SCHEDULE_END_TIME = '21:30'
|
||||
/** 第一节课后的半点课表起点 */
|
||||
export const DEFAULT_SCHEDULE_OFFSET_START_TIME = '09:30'
|
||||
/** 上午时段结束时刻(不含):最后一节为 11:00–12:00 */
|
||||
export const DEFAULT_SCHEDULE_MORNING_END_TIME = '12:00'
|
||||
/** 下午时段起点:13:30–14:30 起,之后每小时一节 */
|
||||
export const DEFAULT_SCHEDULE_AFTERNOON_START_TIME = '13:30'
|
||||
/** @deprecated 使用 DEFAULT_SCHEDULE_AFTERNOON_START_TIME */
|
||||
export const DEFAULT_SCHEDULE_OFFSET_START_TIME = DEFAULT_SCHEDULE_AFTERNOON_START_TIME
|
||||
/** 排课时间选择步长:仅整点与半点 */
|
||||
export const SCHEDULE_MINUTE_STEP = 30
|
||||
|
||||
@@ -37,14 +41,13 @@ function formatMinutesToTime(totalMinutes: number): string {
|
||||
/** 生成默认时段列表 (startTime, endTime) */
|
||||
export function getDefaultTimeSlots(): ReadonlyArray<{ readonly startTime: string; readonly endTime: string }> {
|
||||
const duration = 60
|
||||
const slots: Array<{ startTime: string; endTime: string }> = [
|
||||
{ startTime: DEFAULT_SCHEDULE_START_TIME, endTime: '09:00' },
|
||||
]
|
||||
const endMinutes = parseTimeToMinutes(DEFAULT_SCHEDULE_END_TIME)
|
||||
const slots: Array<{ startTime: string; endTime: string }> = []
|
||||
const morningEnd = parseTimeToMinutes(DEFAULT_SCHEDULE_MORNING_END_TIME)
|
||||
const dayEnd = parseTimeToMinutes(DEFAULT_SCHEDULE_END_TIME)
|
||||
|
||||
for (
|
||||
let t = parseTimeToMinutes(DEFAULT_SCHEDULE_OFFSET_START_TIME);
|
||||
t + duration <= endMinutes;
|
||||
let t = parseTimeToMinutes(DEFAULT_SCHEDULE_START_TIME);
|
||||
t + duration <= morningEnd;
|
||||
t += duration
|
||||
) {
|
||||
slots.push({
|
||||
@@ -52,6 +55,18 @@ export function getDefaultTimeSlots(): ReadonlyArray<{ readonly startTime: strin
|
||||
endTime: formatMinutesToTime(t + duration),
|
||||
})
|
||||
}
|
||||
|
||||
for (
|
||||
let t = parseTimeToMinutes(DEFAULT_SCHEDULE_AFTERNOON_START_TIME);
|
||||
t + duration <= dayEnd;
|
||||
t += duration
|
||||
) {
|
||||
slots.push({
|
||||
startTime: formatMinutesToTime(t),
|
||||
endTime: formatMinutesToTime(t + duration),
|
||||
})
|
||||
}
|
||||
|
||||
return slots
|
||||
}
|
||||
|
||||
|
||||
@@ -62,3 +62,171 @@ export enum SubscriptionMessageScene {
|
||||
CLASS_REMINDER = 'CLASS_REMINDER',
|
||||
ADMIN_BOOKING_CREATED = 'ADMIN_BOOKING_CREATED',
|
||||
}
|
||||
|
||||
// ===== Body portrait =====
|
||||
export enum BodyPortraitSource {
|
||||
XHS = 'xhs',
|
||||
WECHAT = 'wechat',
|
||||
DIANPING = 'dianping',
|
||||
MEMBER_SHARE = 'member_share',
|
||||
ORGANIC = 'organic',
|
||||
OTHER = 'other',
|
||||
}
|
||||
|
||||
export enum BodyPortraitAssessmentStatus {
|
||||
DRAFT = 'DRAFT',
|
||||
COMPLETED = 'COMPLETED',
|
||||
CLAIMED = 'CLAIMED',
|
||||
EXPIRED = 'EXPIRED',
|
||||
}
|
||||
|
||||
export enum BodyRegion {
|
||||
NECK = 'neck',
|
||||
SHOULDER = 'shoulder',
|
||||
UPPER_BACK = 'upperBack',
|
||||
LOW_BACK = 'lowBack',
|
||||
PELVIS = 'pelvis',
|
||||
HIP = 'hip',
|
||||
KNEE = 'knee',
|
||||
LEG = 'leg',
|
||||
}
|
||||
|
||||
export enum PortraitGoal {
|
||||
POSTURE = 'posture',
|
||||
NECK_RELIEF = 'neckRelief',
|
||||
BACK_COMFORT = 'backComfort',
|
||||
CORE = 'core',
|
||||
SHAPING = 'shaping',
|
||||
HIP_LEG = 'hipLeg',
|
||||
STABILITY = 'stability',
|
||||
OTHER = 'other',
|
||||
}
|
||||
|
||||
export enum SittingHours {
|
||||
LT4 = 'lt4',
|
||||
H4_TO_6 = 'h4to6',
|
||||
H6_TO_8 = 'h6to8',
|
||||
GT8 = 'gt8',
|
||||
}
|
||||
|
||||
export enum ExerciseFreq {
|
||||
NONE = 'none',
|
||||
W1_TO_2 = 'w1to2',
|
||||
W3_TO_4 = 'w3to4',
|
||||
W5_PLUS = 'w5plus',
|
||||
}
|
||||
|
||||
export enum WorkPosture {
|
||||
COMPUTER = 'computer',
|
||||
PHONE = 'phone',
|
||||
STANDING = 'standing',
|
||||
WALKING = 'walking',
|
||||
HOLDING_CHILD = 'holdingChild',
|
||||
OTHER = 'other',
|
||||
}
|
||||
|
||||
export enum AfterSitting {
|
||||
NECK_TIGHT = 'neckTight',
|
||||
LOW_BACK_ACHE = 'lowBackAche',
|
||||
BACK_STIFF = 'backStiff',
|
||||
HIP_TIGHT = 'hipTight',
|
||||
NONE = 'none',
|
||||
}
|
||||
|
||||
export enum StandingNotice {
|
||||
HEAD_FORWARD = 'headForward',
|
||||
ROUNDED_SHOULDERS = 'roundedShoulders',
|
||||
UNEVEN_SHOULDERS = 'unevenShoulders',
|
||||
BELLY_FORWARD = 'bellyForward',
|
||||
LOCKED_KNEES = 'lockedKnees',
|
||||
UNNOTICED = 'unnoticed',
|
||||
}
|
||||
|
||||
export enum SafetyFlag {
|
||||
PERSISTENT_PAIN = 'persistentPain',
|
||||
RECENT_INJURY = 'recentInjury',
|
||||
NUMBNESS = 'numbness',
|
||||
DOCTOR_LIMIT = 'doctorLimit',
|
||||
MEDICAL_RECOVERY = 'medicalRecovery',
|
||||
NONE = 'none',
|
||||
}
|
||||
|
||||
export enum BodyDimension {
|
||||
CERVICAL_SHOULDER = 'cervicalShoulder',
|
||||
SPINAL_MOBILITY = 'spinalMobility',
|
||||
CORE_CONTROL = 'coreControl',
|
||||
HIP_PELVIS = 'hipPelvis',
|
||||
LOWER_LIMB = 'lowerLimb',
|
||||
}
|
||||
|
||||
export enum ConcernLevel {
|
||||
LOW = 'low',
|
||||
MILD = 'mild',
|
||||
MODERATE = 'moderate',
|
||||
HIGH = 'high',
|
||||
PRIORITY = 'priority',
|
||||
}
|
||||
|
||||
export enum PortraitType {
|
||||
SEDENTARY_NECK_TENSION = 'sedentaryNeckTension',
|
||||
SEDENTARY_CORE_WEAK = 'sedentaryCoreWeak',
|
||||
NECK_COMPENSATION = 'neckCompensation',
|
||||
HIP_TIGHT = 'hipTight',
|
||||
UPPER_LOWER_CROSS = 'upperLowerCross',
|
||||
LOWER_LIMB_UNSTABLE = 'lowerLimbUnstable',
|
||||
MIXED_FATIGUE = 'mixedFatigue',
|
||||
LOW_OVERALL_CONCERN = 'lowOverallConcern',
|
||||
}
|
||||
|
||||
export enum PortraitMatchQuality {
|
||||
FULL = 'full',
|
||||
PARTIAL = 'partial',
|
||||
}
|
||||
|
||||
export enum GrowthLeadStage {
|
||||
VISIT = 'VISIT',
|
||||
STARTED = 'STARTED',
|
||||
COMPLETED = 'COMPLETED',
|
||||
CLAIMED = 'CLAIMED',
|
||||
PHONE_BOUND = 'PHONE_BOUND',
|
||||
TRIAL_PURCHASED = 'TRIAL_PURCHASED',
|
||||
TRIAL_BOOKED = 'TRIAL_BOOKED',
|
||||
TRIAL_ATTENDED = 'TRIAL_ATTENDED',
|
||||
PLAN_PURCHASED = 'PLAN_PURCHASED',
|
||||
TRAINING = 'TRAINING',
|
||||
RENEWAL_DUE = 'RENEWAL_DUE',
|
||||
RENEWED = 'RENEWED',
|
||||
}
|
||||
|
||||
export enum GrowthEventName {
|
||||
ASSESSMENT_STARTED = 'assessment_started',
|
||||
ASSESSMENT_COMPLETED = 'assessment_completed',
|
||||
ASSESSMENT_CLAIMED = 'assessment_claimed',
|
||||
REPORT_VIEWED = 'report_viewed',
|
||||
ADVICE_VIEWED = 'advice_viewed',
|
||||
PHONE_BOUND = 'phone_bound',
|
||||
TRIAL_CLICKED = 'trial_clicked',
|
||||
TRIAL_PURCHASED = 'trial_purchased',
|
||||
TRIAL_BOOKED = 'trial_booked',
|
||||
TRIAL_ATTENDED = 'trial_attended',
|
||||
PLAN_PURCHASED = 'plan_purchased',
|
||||
REPORT_SHARED = 'report_shared',
|
||||
}
|
||||
|
||||
export enum ProfessionalAssessmentKind {
|
||||
INITIAL = 'INITIAL',
|
||||
FOLLOW_UP = 'FOLLOW_UP',
|
||||
}
|
||||
|
||||
export enum TrainingPlanStatus {
|
||||
DRAFT = 'DRAFT',
|
||||
ACTIVE = 'ACTIVE',
|
||||
COMPLETED = 'COMPLETED',
|
||||
}
|
||||
|
||||
export enum ProgressPhotoAngle {
|
||||
FRONT = 'FRONT',
|
||||
SIDE = 'SIDE',
|
||||
BACK = 'BACK',
|
||||
DETAIL = 'DETAIL',
|
||||
}
|
||||
|
||||
@@ -9,6 +9,25 @@ export {
|
||||
OrderStatus,
|
||||
InviteReferralStatus,
|
||||
SubscriptionMessageScene,
|
||||
BodyPortraitSource,
|
||||
BodyPortraitAssessmentStatus,
|
||||
BodyRegion,
|
||||
PortraitGoal,
|
||||
SittingHours,
|
||||
ExerciseFreq,
|
||||
WorkPosture,
|
||||
AfterSitting,
|
||||
StandingNotice,
|
||||
SafetyFlag,
|
||||
BodyDimension,
|
||||
ConcernLevel,
|
||||
PortraitType,
|
||||
PortraitMatchQuality,
|
||||
GrowthLeadStage,
|
||||
GrowthEventName,
|
||||
ProfessionalAssessmentKind,
|
||||
TrainingPlanStatus,
|
||||
ProgressPhotoAngle,
|
||||
} from './enums'
|
||||
|
||||
// Constants
|
||||
@@ -19,6 +38,8 @@ export {
|
||||
SLOT_GENERATION_DAYS,
|
||||
DEFAULT_SCHEDULE_START_TIME,
|
||||
DEFAULT_SCHEDULE_END_TIME,
|
||||
DEFAULT_SCHEDULE_MORNING_END_TIME,
|
||||
DEFAULT_SCHEDULE_AFTERNOON_START_TIME,
|
||||
DEFAULT_SCHEDULE_OFFSET_START_TIME,
|
||||
SCHEDULE_MINUTE_STEP,
|
||||
getDefaultTimeSlots,
|
||||
@@ -116,3 +137,4 @@ export type {
|
||||
} from './types/index'
|
||||
|
||||
export * from './types/member-care'
|
||||
export * from './types/body-portrait'
|
||||
|
||||
427
packages/shared/src/types/body-portrait.ts
Normal file
427
packages/shared/src/types/body-portrait.ts
Normal file
@@ -0,0 +1,427 @@
|
||||
import {
|
||||
AfterSitting,
|
||||
BodyDimension,
|
||||
BodyPortraitAssessmentStatus,
|
||||
BodyPortraitSource,
|
||||
BodyRegion,
|
||||
ConcernLevel,
|
||||
ExerciseFreq,
|
||||
GrowthEventName,
|
||||
GrowthLeadStage,
|
||||
PortraitGoal,
|
||||
PortraitMatchQuality,
|
||||
PortraitType,
|
||||
ProfessionalAssessmentKind,
|
||||
ProgressPhotoAngle,
|
||||
SafetyFlag,
|
||||
SittingHours,
|
||||
StandingNotice,
|
||||
TrainingPlanStatus,
|
||||
WorkPosture,
|
||||
} from '../enums'
|
||||
|
||||
export const BODY_PORTRAIT_QUESTIONNAIRE_VERSION = 'portrait-q-v1'
|
||||
export const BODY_PORTRAIT_RULES_VERSION = 'portrait-r-v1'
|
||||
export const BODY_PORTRAIT_OFFLINE_PROTOCOL = 'offline-v1'
|
||||
export const BODY_PORTRAIT_DISCLAIMER =
|
||||
'本评估不是医疗诊断或体态疾病检测,仅用于运动训练参考。若你正处于明显疼痛、伤病或医生限制运动的阶段,请先由医生或相应专业人士确认运动条件。'
|
||||
|
||||
export const BODY_REGION_LABELS: Record<BodyRegion, string> = {
|
||||
[BodyRegion.NECK]: '头颈',
|
||||
[BodyRegion.SHOULDER]: '肩',
|
||||
[BodyRegion.UPPER_BACK]: '上背',
|
||||
[BodyRegion.LOW_BACK]: '腰',
|
||||
[BodyRegion.PELVIS]: '骨盆',
|
||||
[BodyRegion.HIP]: '髋',
|
||||
[BodyRegion.KNEE]: '膝',
|
||||
[BodyRegion.LEG]: '腿',
|
||||
}
|
||||
|
||||
export const PORTRAIT_GOAL_LABELS: Record<PortraitGoal, string> = {
|
||||
[PortraitGoal.POSTURE]: '看起来更挺拔',
|
||||
[PortraitGoal.NECK_RELIEF]: '肩颈没那么累',
|
||||
[PortraitGoal.BACK_COMFORT]: '久坐更舒服',
|
||||
[PortraitGoal.CORE]: '腹部更有力量',
|
||||
[PortraitGoal.SHAPING]: '身体线条更好',
|
||||
[PortraitGoal.HIP_LEG]: '腿型/髋部状态改善',
|
||||
[PortraitGoal.STABILITY]: '动作更稳定',
|
||||
[PortraitGoal.OTHER]: '我有自己的目标',
|
||||
}
|
||||
|
||||
export const SITTING_HOURS_LABELS: Record<SittingHours, string> = {
|
||||
[SittingHours.LT4]: '不到 4 小时',
|
||||
[SittingHours.H4_TO_6]: '4~6 小时',
|
||||
[SittingHours.H6_TO_8]: '6~8 小时',
|
||||
[SittingHours.GT8]: '超过 8 小时',
|
||||
}
|
||||
|
||||
export const EXERCISE_FREQ_LABELS: Record<ExerciseFreq, string> = {
|
||||
[ExerciseFreq.NONE]: '几乎不运动',
|
||||
[ExerciseFreq.W1_TO_2]: '1~2 次/周',
|
||||
[ExerciseFreq.W3_TO_4]: '3~4 次/周',
|
||||
[ExerciseFreq.W5_PLUS]: '5 次以上',
|
||||
}
|
||||
|
||||
export const WORK_POSTURE_LABELS: Record<WorkPosture, string> = {
|
||||
[WorkPosture.COMPUTER]: '电脑办公',
|
||||
[WorkPosture.PHONE]: '低头手机',
|
||||
[WorkPosture.STANDING]: '久站',
|
||||
[WorkPosture.WALKING]: '频繁走动',
|
||||
[WorkPosture.HOLDING_CHILD]: '抱娃',
|
||||
[WorkPosture.OTHER]: '其他',
|
||||
}
|
||||
|
||||
export const AFTER_SITTING_LABELS: Record<AfterSitting, string> = {
|
||||
[AfterSitting.NECK_TIGHT]: '肩颈紧',
|
||||
[AfterSitting.LOW_BACK_ACHE]: '腰酸',
|
||||
[AfterSitting.BACK_STIFF]: '背部僵硬',
|
||||
[AfterSitting.HIP_TIGHT]: '臀腿紧',
|
||||
[AfterSitting.NONE]: '没明显感觉',
|
||||
}
|
||||
|
||||
export const STANDING_NOTICE_LABELS: Record<StandingNotice, string> = {
|
||||
[StandingNotice.HEAD_FORWARD]: '头容易往前',
|
||||
[StandingNotice.ROUNDED_SHOULDERS]: '肩膀向内',
|
||||
[StandingNotice.UNEVEN_SHOULDERS]: '左右肩不一样高',
|
||||
[StandingNotice.BELLY_FORWARD]: '肚子容易往前顶',
|
||||
[StandingNotice.LOCKED_KNEES]: '膝盖容易锁死',
|
||||
[StandingNotice.UNNOTICED]: '没有注意过',
|
||||
}
|
||||
|
||||
export const SAFETY_FLAG_LABELS: Record<SafetyFlag, string> = {
|
||||
[SafetyFlag.PERSISTENT_PAIN]: '持续明显疼痛',
|
||||
[SafetyFlag.RECENT_INJURY]: '最近受伤',
|
||||
[SafetyFlag.NUMBNESS]: '手脚明显麻木',
|
||||
[SafetyFlag.DOCTOR_LIMIT]: '医生要求限制运动',
|
||||
[SafetyFlag.MEDICAL_RECOVERY]: '正处于需要专业医疗评估的恢复阶段',
|
||||
[SafetyFlag.NONE]: '以上都没有',
|
||||
}
|
||||
|
||||
export const BODY_DIMENSION_LABELS: Record<BodyDimension, string> = {
|
||||
[BodyDimension.CERVICAL_SHOULDER]: '肩颈状态',
|
||||
[BodyDimension.SPINAL_MOBILITY]: '脊柱活动',
|
||||
[BodyDimension.CORE_CONTROL]: '核心控制',
|
||||
[BodyDimension.HIP_PELVIS]: '髋骨盆状态',
|
||||
[BodyDimension.LOWER_LIMB]: '下肢稳定',
|
||||
}
|
||||
|
||||
export const CONCERN_LEVEL_LABELS: Record<ConcernLevel, string> = {
|
||||
[ConcernLevel.LOW]: '低关注',
|
||||
[ConcernLevel.MILD]: '轻度关注',
|
||||
[ConcernLevel.MODERATE]: '中度关注',
|
||||
[ConcernLevel.HIGH]: '高关注',
|
||||
[ConcernLevel.PRIORITY]: '重点关注',
|
||||
}
|
||||
|
||||
export const PORTRAIT_TYPE_LABELS: Record<PortraitType, string> = {
|
||||
[PortraitType.SEDENTARY_NECK_TENSION]: '久坐肩颈紧张型',
|
||||
[PortraitType.SEDENTARY_CORE_WEAK]: '久坐核心弱化型',
|
||||
[PortraitType.NECK_COMPENSATION]: '肩颈代偿型',
|
||||
[PortraitType.HIP_TIGHT]: '髋部紧张型',
|
||||
[PortraitType.UPPER_LOWER_CROSS]: '上下交叉混合型',
|
||||
[PortraitType.LOWER_LIMB_UNSTABLE]: '下肢稳定不足型',
|
||||
[PortraitType.MIXED_FATIGUE]: '全身疲劳累积型',
|
||||
[PortraitType.LOW_OVERALL_CONCERN]: '整体关注度较低型',
|
||||
}
|
||||
|
||||
export const GROWTH_LEAD_STAGE_LABELS: Record<GrowthLeadStage, string> = {
|
||||
[GrowthLeadStage.VISIT]: '访问',
|
||||
[GrowthLeadStage.STARTED]: '开始测评',
|
||||
[GrowthLeadStage.COMPLETED]: '完成报告',
|
||||
[GrowthLeadStage.CLAIMED]: '已认领',
|
||||
[GrowthLeadStage.PHONE_BOUND]: '已留联系方式',
|
||||
[GrowthLeadStage.TRIAL_PURCHASED]: '已购体验',
|
||||
[GrowthLeadStage.TRIAL_BOOKED]: '已预约',
|
||||
[GrowthLeadStage.TRIAL_ATTENDED]: '已到店',
|
||||
[GrowthLeadStage.PLAN_PURCHASED]: '已购买计划',
|
||||
[GrowthLeadStage.TRAINING]: '训练中',
|
||||
[GrowthLeadStage.RENEWAL_DUE]: '待续费',
|
||||
[GrowthLeadStage.RENEWED]: '已续费',
|
||||
}
|
||||
|
||||
export interface BodyPortraitAnswers {
|
||||
readonly concerns: readonly BodyRegion[]
|
||||
readonly goal: PortraitGoal | null
|
||||
readonly sittingHours: SittingHours | null
|
||||
readonly exerciseFreq: ExerciseFreq | null
|
||||
readonly workPosture: WorkPosture | null
|
||||
readonly endOfDayFatigue: readonly BodyRegion[]
|
||||
readonly afterSitting: readonly AfterSitting[]
|
||||
readonly standingNotice: readonly StandingNotice[]
|
||||
readonly safety: readonly SafetyFlag[]
|
||||
}
|
||||
|
||||
export interface BodyPortraitScores {
|
||||
readonly cervicalShoulder: number
|
||||
readonly spinalMobility: number
|
||||
readonly coreControl: number
|
||||
readonly hipPelvis: number
|
||||
readonly lowerLimb: number
|
||||
}
|
||||
|
||||
export interface BodyPortraitLevels {
|
||||
readonly cervicalShoulder: ConcernLevel
|
||||
readonly spinalMobility: ConcernLevel
|
||||
readonly coreControl: ConcernLevel
|
||||
readonly hipPelvis: ConcernLevel
|
||||
readonly lowerLimb: ConcernLevel
|
||||
}
|
||||
|
||||
export interface BodyPortraitEvidence {
|
||||
readonly dimension: BodyDimension
|
||||
readonly title: string
|
||||
readonly answers: readonly string[]
|
||||
readonly explanation: string
|
||||
}
|
||||
|
||||
export interface BodyPortraitAdviceItem {
|
||||
readonly title: string
|
||||
readonly detail: string
|
||||
readonly stopIf: string
|
||||
}
|
||||
|
||||
export interface BodyPortraitAdvice {
|
||||
readonly id: string
|
||||
readonly items: readonly BodyPortraitAdviceItem[]
|
||||
}
|
||||
|
||||
export interface BodyPortraitChain {
|
||||
readonly steps: readonly string[]
|
||||
readonly takeaway: string
|
||||
}
|
||||
|
||||
export interface BodyPortraitSafetyResult {
|
||||
readonly flagged: boolean
|
||||
readonly flags: readonly SafetyFlag[]
|
||||
readonly message: string | null
|
||||
}
|
||||
|
||||
export interface BodyPortraitReport {
|
||||
readonly questionnaireVersion: string
|
||||
readonly rulesVersion: string
|
||||
readonly scores: BodyPortraitScores
|
||||
readonly levels: BodyPortraitLevels
|
||||
readonly primaryType: PortraitType
|
||||
readonly secondaryType: PortraitType | null
|
||||
readonly headline: string
|
||||
readonly summary: string
|
||||
readonly evidence: readonly BodyPortraitEvidence[]
|
||||
readonly chain: BodyPortraitChain
|
||||
readonly advice: BodyPortraitAdvice | null
|
||||
readonly matchQuality: PortraitMatchQuality
|
||||
readonly safety: BodyPortraitSafetyResult
|
||||
readonly firstAssessmentHints: readonly string[]
|
||||
readonly goalLabel: string | null
|
||||
}
|
||||
|
||||
export interface BodyPortraitTeaser {
|
||||
readonly assessmentId: string
|
||||
readonly headline: string
|
||||
readonly primaryType: PortraitType
|
||||
readonly primaryTypeLabel: string
|
||||
readonly topDimension: BodyDimension | null
|
||||
readonly topDimensionLabel: string | null
|
||||
readonly matchQuality: PortraitMatchQuality
|
||||
readonly safetyFlagged: boolean
|
||||
readonly disclaimer: string
|
||||
}
|
||||
|
||||
export interface BodyPortraitVisitResponse {
|
||||
readonly visitId: string
|
||||
readonly visitToken: string
|
||||
readonly source: BodyPortraitSource
|
||||
}
|
||||
|
||||
export interface BodyPortraitSessionResponse {
|
||||
readonly assessmentId: string
|
||||
readonly accessToken: string
|
||||
readonly visitToken: string
|
||||
readonly questionnaireVersion: string
|
||||
readonly status: BodyPortraitAssessmentStatus
|
||||
readonly answers: BodyPortraitAnswers
|
||||
readonly teaser: BodyPortraitTeaser | null
|
||||
readonly report: BodyPortraitReport | null
|
||||
readonly claimed: boolean
|
||||
}
|
||||
|
||||
export interface BodyPortraitStats {
|
||||
readonly completedCount: number
|
||||
}
|
||||
|
||||
export interface CreateBodyPortraitVisitDto {
|
||||
readonly source?: BodyPortraitSource | string
|
||||
readonly campaignId?: string
|
||||
readonly referralCode?: string
|
||||
readonly landingPath?: string
|
||||
}
|
||||
|
||||
export interface SaveBodyPortraitAnswersDto {
|
||||
readonly accessToken: string
|
||||
readonly answers: Partial<BodyPortraitAnswers>
|
||||
}
|
||||
|
||||
export interface BodyPortraitAccessDto {
|
||||
readonly accessToken: string
|
||||
}
|
||||
|
||||
export interface TrackGrowthEventDto {
|
||||
readonly name: GrowthEventName
|
||||
readonly accessToken?: string
|
||||
readonly idempotencyKey?: string
|
||||
}
|
||||
|
||||
export interface GrowthEventRecord {
|
||||
readonly id: string
|
||||
readonly name: GrowthEventName
|
||||
readonly occurredAt: string
|
||||
}
|
||||
|
||||
export interface GrowthLeadSummary {
|
||||
readonly id: string
|
||||
readonly userId: string
|
||||
readonly nickname: string
|
||||
readonly phone: string | null
|
||||
readonly stage: GrowthLeadStage
|
||||
readonly source: BodyPortraitSource | null
|
||||
readonly campaignId: string | null
|
||||
readonly primaryType: PortraitType | null
|
||||
readonly topDimension: BodyDimension | null
|
||||
readonly safetyFlagged: boolean
|
||||
readonly lastActiveAt: string
|
||||
readonly completedAt: string | null
|
||||
readonly followUpDraft: string
|
||||
}
|
||||
|
||||
export interface GrowthLeadDetail extends GrowthLeadSummary {
|
||||
readonly goalLabel: string | null
|
||||
readonly scores: BodyPortraitScores | null
|
||||
readonly levels: BodyPortraitLevels | null
|
||||
readonly evidence: readonly BodyPortraitEvidence[]
|
||||
readonly answers: BodyPortraitAnswers | null
|
||||
readonly report: BodyPortraitReport | null
|
||||
readonly events: readonly GrowthEventRecord[]
|
||||
readonly coachNote: string
|
||||
readonly firstAssessmentHints: readonly string[]
|
||||
}
|
||||
|
||||
export interface GrowthTodayTask {
|
||||
readonly kind: 'completed_unbooked' | 'trial_unpurchased' | 'reassessment_due'
|
||||
readonly leadId: string
|
||||
readonly userId: string
|
||||
readonly nickname: string
|
||||
readonly title: string
|
||||
readonly detail: string
|
||||
readonly happenedAt: string
|
||||
readonly followUpDraft: string
|
||||
}
|
||||
|
||||
export interface GrowthFunnelRow {
|
||||
readonly source: BodyPortraitSource
|
||||
readonly started: number
|
||||
readonly completed: number
|
||||
readonly claimed: number
|
||||
readonly booked: number
|
||||
readonly attended: number
|
||||
readonly purchased: number
|
||||
}
|
||||
|
||||
export interface GrowthTodayDashboard {
|
||||
readonly tasks: readonly GrowthTodayTask[]
|
||||
readonly funnel: readonly GrowthFunnelRow[]
|
||||
}
|
||||
|
||||
export interface ProfessionalObservationScores {
|
||||
readonly headPosition: number
|
||||
readonly shoulderPosition: number
|
||||
readonly thoracicExtension: number
|
||||
readonly shoulderFlexion: number
|
||||
readonly breathing: number
|
||||
readonly pelvis: number
|
||||
readonly coreControl: number
|
||||
readonly hipMobility: number
|
||||
readonly singleLeg: number
|
||||
}
|
||||
|
||||
export interface ProfessionalAssessmentSessionRecord {
|
||||
readonly id: string
|
||||
readonly userId: string
|
||||
readonly kind: ProfessionalAssessmentKind
|
||||
readonly protocolVersion: string
|
||||
readonly recordedAt: string
|
||||
readonly bookingId: string | null
|
||||
readonly originAssessmentId: string | null
|
||||
readonly observations: ProfessionalObservationScores
|
||||
readonly subjectiveTension: number | null
|
||||
readonly coachSummary: string
|
||||
readonly trainingFocus: string
|
||||
readonly phaseGoal: string
|
||||
readonly photoAngle: ProgressPhotoAngle | null
|
||||
}
|
||||
|
||||
export interface CreateProfessionalAssessmentDto {
|
||||
readonly kind: ProfessionalAssessmentKind
|
||||
readonly recordedAt: string
|
||||
readonly bookingId?: string
|
||||
readonly originAssessmentId?: string
|
||||
readonly observations: ProfessionalObservationScores
|
||||
readonly subjectiveTension?: number | null
|
||||
readonly coachSummary: string
|
||||
readonly trainingFocus: string
|
||||
readonly phaseGoal: string
|
||||
readonly photoAngle?: ProgressPhotoAngle | null
|
||||
}
|
||||
|
||||
export interface TrainingPlanPhaseRecord {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly lessonStart: number
|
||||
readonly lessonEnd: number
|
||||
readonly focus: readonly string[]
|
||||
readonly summary: string
|
||||
}
|
||||
|
||||
export interface TrainingPlanRecord {
|
||||
readonly id: string
|
||||
readonly userId: string
|
||||
readonly title: string
|
||||
readonly status: TrainingPlanStatus
|
||||
readonly weeks: number
|
||||
readonly sessionId: string | null
|
||||
readonly phases: readonly TrainingPlanPhaseRecord[]
|
||||
}
|
||||
|
||||
export interface CreateTrainingPlanDto {
|
||||
readonly sessionId?: string
|
||||
readonly title?: string
|
||||
readonly weeks?: number
|
||||
}
|
||||
|
||||
export interface ReassessmentTodoRecord {
|
||||
readonly id: string
|
||||
readonly userId: string
|
||||
readonly planId: string
|
||||
readonly lessonCheckpoint: number
|
||||
readonly dueAt: string
|
||||
readonly completedAt: string | null
|
||||
}
|
||||
|
||||
export interface GrowthComparisonRow {
|
||||
readonly label: string
|
||||
readonly first: string
|
||||
readonly latest: string
|
||||
}
|
||||
|
||||
export interface GrowthShareCardRecord {
|
||||
readonly id: string
|
||||
readonly shareCode: string
|
||||
readonly title: string
|
||||
readonly caption: string
|
||||
readonly completedCount: number
|
||||
readonly weeks: number
|
||||
readonly rows: readonly GrowthComparisonRow[]
|
||||
readonly includePhotos: boolean
|
||||
}
|
||||
|
||||
export interface CreateGrowthShareCardDto {
|
||||
readonly includePhotos?: boolean
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export interface Booking {
|
||||
readonly confirmedAt: string | null
|
||||
readonly completedAt: string | null
|
||||
readonly operatorId: string | null
|
||||
readonly originAssessmentId?: string | null
|
||||
readonly createdAt: string
|
||||
readonly updatedAt: string
|
||||
}
|
||||
@@ -69,6 +70,7 @@ export interface BookingStatusHistory {
|
||||
export interface CreateBookingDto {
|
||||
readonly timeSlotId: string
|
||||
readonly membershipId: string
|
||||
readonly originAssessmentId?: string
|
||||
}
|
||||
|
||||
export interface AdminArrangeBookingDto {
|
||||
|
||||
@@ -53,3 +53,4 @@ export type {
|
||||
} from './invite'
|
||||
|
||||
export type { TeachingAnalytics, TeachingAnalyticsRecord, TeachingAnalyticsSummary } from './teaching-analytics'
|
||||
export * from './body-portrait'
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface MemberNoteRecord {
|
||||
booking: { timeSlot: { date: string; startTime: string } } | null
|
||||
}
|
||||
export interface ProgressPhotoRecord {
|
||||
id: string; caption: string; recordedAt: string; consentedAt: string | null; revokedAt: string | null
|
||||
id: string; caption: string; recordedAt: string; consentedAt: string | null; revokedAt: string | null; angle?: string | null; sessionId?: string | null
|
||||
}
|
||||
export interface ProgressArchive {
|
||||
nickname: string; completedCount: number; milestones: number[]; metrics: BodyMetricRecord[]; notes: MemberNoteRecord[]; photos: ProgressPhotoRecord[]
|
||||
|
||||
Reference in New Issue
Block a user