feat: 支持新版会员档案

This commit is contained in:
richarjiang
2026-10-18 14:59:48 +08:00
parent 21e3ece21c
commit b452de379d
23 changed files with 731 additions and 54 deletions

View File

@@ -0,0 +1,63 @@
# 会员体测与体态检查表
会员档案 → 成长档案 → 体测 → 新建记录 / 查看 / 编辑。会员自己的成长档案可查看完整记录;新增、修改和删除沿用馆主权限。
## 纸质内容对应
检查项目按用户提供的《体态分析指南 检查表》照片逐项录入。共同定义位于 `packages/shared/src/posture-assessment.ts`,前端与服务端共用,避免选项漂移。
| 分区 | 内容 |
| --- | --- |
| 铅垂线 | 身体各部分与铅垂线相符;头部、胸部和骨盆排列对齐 |
| 侧视图(两侧均需检查) | 踝关节、膝部、髋关节、骨盆、腰椎、下胸椎、上胸椎、颈椎、头部 |
| 正面视图(两侧均需检查) | 足部、膝部、骨盆、肋骨架、肩部、头部;保留「如有必要,可参照后视图加以确认」提示 |
| 后视图(两侧均需检查) | 足部、股骨、骨盆、肩胛骨、肱骨 |
| 循序检查脊柱 | 从侧面观察是否有平直部位及位置;从背后观察并触碰检查是否有失衡部位及位置 |
保留纸上每一行的右/左栏;无左右栏的项目按整体记录。左右始终指会员自身。不同解剖平面的观察可以并存,同一侧中立位与异常、同一平面的相反观察互斥。铅垂线和脊柱问题区分「是」「否」「未检查」,没有自动勾选正常项。脊柱选项从「是」改为「否」或未检查时会清空相应位置。
## 数据与兼容性
- 沿用 `body_metrics`、现有记录 ID、日期、体重、体脂率、腰围、臀围、柔韧度和备注,不删除或重建历史记录。
- 仅新增可空的 `height DOUBLE NULL` 和 `assessment JSON NULL`。历史行无需回填,体态内容为空时显示原始测量,并允许打开详情、编辑和补充检查项。
- `assessment` 使用 `version: 1`、检查行 ID、左右标记、铅垂线和脊柱观察保存。未选项目不推定为正常。
- 所有数值字段及检查项目均可选。至少需要一项测量、检查结果或非空备注,防止误存完全空白的记录。
- 保留 `POST /admin/members/:userId/progress/metrics`;旧客户端无需提交新增字段,仍可保存原有数据。
- 新增 `GET .../metrics/:id`(馆主与会员各自的 progress 路径)和 `PUT /admin/members/:userId/progress/metrics/:id`。读取和修改均限定记录所属会员,会员端仅有读取权限。
- PUT 为整份记录保存:未填写的数值写入 NULL、取消的检查项清空,修改原记录而非新增一条。服务端校验日期、数值范围、检查行、左右标记及互斥约束。
## 发布顺序
1. 使用现有发布流程备份数据库,并在服务端执行 `pnpm --filter @mp-pilates/server exec prisma migrate deploy`,应用 `20261018070000_posture_assessment`。这是仅新增可空列的迁移,原服务仍能使用原字段。
2. 生成 Prisma Client、构建 shared/server 后发布服务端。
3. 构建并发布小程序。旧版小程序在过渡期仍可通过原 POST 接口保存测量,新版可查看和编辑所有历史记录。
没有新环境变量。本地开发不会自动连接生产数据库或执行生产迁移。
## 验证重点
已有会员的旧测量原值展示、旧版请求无需新字段、新增仅体态检查、空数值清除、编辑后不新增副本、左右侧独立、多平面并存、相反结果互斥、记录归属校验、未保存离开提醒、保存失败保留表单,以及返回档案后刷新。
## 生产发布记录(2026-10-18)
按用户授权,将体测功能与此前遗漏的商城「取消取货码、管理员直接完成交付」服务端改动一并发布。商城增加了旧版小程序响应兼容层,避免旧页面缺少取货码而无法提交交付。
- 服务器:沿用 `/usr/local/web/mp-pilates-server`、`/usr/local/web/mp-pilates-shared` 和 PM2 `mp-pilates-server`,Node 22.16.0、Prisma 5.22.0;保留现有环境配置、支付证书和依赖。
- 成功应用 `20260925111500_drop_product_order_pickup_code` 与 `20261018070000_posture_assessment`,生产数据库的 13 个迁移均已完成。
- 采用短暂停服的同步切换,更新 server/shared 构建、schema 和原生 Prisma Client 后重启;PM2 正常在线,没有异常重启,内网和公网健康检查均为 200。
- 对迁移前后记录按 ID 排序并校验完整字段摘要:3 条历史体测原值完全一致;3 条商品订单除被移除的取货码外,其余原值完全一致。历史体测新增字段为 NULL,且数据库列允许 NULL。
- 13 项线上接口检查通过:体测档案和详情读取、会员归属隔离、商城订单列表的旧版兼容码、管理汇总、匿名访问限制、会员管理权限限制和非法分页校验。
- 发布前完整服务端测试为 31 个套件、480 项全部通过;shared/server 构建与小程序类型检查通过。没有创建真实测试订单、扣款或修改历史体测来做验证。
- 发布包 SHA-256:`ad993fe92dd06b43eb4f33963dd3a72642debb5e9c501880c953e05643d1dd4c`。
备份和验证报告仅保存在服务器受限目录 `/usr/local/web/releases/posture-20261018-1792305938060/`:
- `database-before.sql.gz`:完整逻辑备份,gzip 完整性检查通过。
- `previous-build.tgz`、`prisma-client-before/`:旧运行文件、共享包、schema/迁移和生成的 Client。
- `pickup-codes-at-switch.json`:切换时的旧取货码,供必要时精确恢复旧商城版本使用。
- `data-baseline-at-switch.json`、`verification.json`:切换前的数据摘要及发布后的验证报告,不包含访问令牌。
- `release.tgz`、`stage/`:本次发布包及解压后的暂存文件。
回滚旧商城版本时,需要同时恢复旧 `pickup_code` 列及对应值、旧运行文件和 Prisma Client,并核对迁移元数据,不能仅重启旧构建。体测新增的可空列可保留。完整数据库恢复用于灾难恢复,正常回滚不得覆盖上线后产生的新数据。
本次仅发布服务端。新的体测界面与商城发货界面仍需随小程序新版发布;已发布的小程序通过服务端兼容层继续使用。

View File

@@ -40,6 +40,12 @@
状态:`PENDING → PAID → COMPLETED`;`PENDING → CANCELLED`。商品可下架但不删除,保证历史订单有效。
### 已发布小程序的过渡兼容(2026-10-18)
数据库删除 `pickup_code` 后,商城订单响应暂时保留可选的 `pickupCode` 字段,由订单 ID 派生为稳定的 12 位大写十六进制展示值。旧版小程序仍可展示、输入并提交这个值;新版小程序不使用它。它不落库,也不作为交付凭证或鉴权依据。完成订单仍必须通过馆主权限及 PAID 状态检查,新旧请求体均可接受。待旧版小程序退出使用后可移除此响应兼容层。
此兼容层及删列迁移已于 2026-10-18 与体测服务一并上线。旧订单除取货码外的原始字段摘要保持一致,线上订单列表、兼容字段和权限验证通过;详见 [本次生产发布记录](posture-assessment.md#生产发布记录2026-10-18)。
## 接口
公开:`GET /shop/products`、`GET /shop/products/:id`。

View File

@@ -0,0 +1,37 @@
<template>
<view class="assessments">
<view class="section-top"><view><text class="eyebrow">POSTURE JOURNAL</text><text class="title">体态与身体评估</text></view><button v-if="admin" class="add" @tap="open()">+ 新建记录</button></view>
<text class="intro">从每一个细节,慢慢了解身体。<text>铅垂线 · 侧视 · 正面 · 后视 · 脊柱</text></text>
<view v-if="!records.length" class="empty"><view class="empty-symbol">+</view><text class="empty-title">第一份身体观察,从这里开始</text><text>可只记录体态检查,身高、体重等均为选填。</text><button v-if="admin" class="add empty-add" @tap="open()">开始评估</button></view>
<view v-for="record in records" :key="record.id" class="record">
<view class="record-head"><view class="date"><text class="day">{{ record.recordedAt.slice(8, 10) }}</text><view><text class="month">{{ record.recordedAt.slice(0, 7).replace('-', ' / ') }}</text><text class="record-label">{{ record.assessment ? '体态分析指南检查表' : '身体刻度记录' }}</text></view></view><text class="arrow" @tap="open(record)">查看 ›</text></view>
<view v-if="record.assessment" class="scope"><text>{{ postureAnswerCount(record.assessment) }} 项检查已记录</text><text>{{ completedViews(record) }} / 5 个检查分区</text></view>
<view v-else class="scope"><text>历史体测记录</text><text>原始数据已保留</text></view>
<view v-if="measurements(record).length" class="measurements"><text v-for="item in measurements(record)" :key="item.key">{{ item.label }} <text class="value">{{ record[item.key] }}</text> {{ item.unit }}</text></view>
<text v-if="record.remark" class="remark">{{ record.remark }}</text>
<view class="record-actions"><button class="text-button" @tap="open(record)">查看完整检查表</button><view v-if="admin" class="admin-actions"><button class="text-button edit" @tap="open(record, true)">编辑</button><button class="text-button delete" @tap="$emit('remove', record.id)">删除</button></view></view>
</view>
</view>
</template>
<script setup lang="ts">
import { BODY_MEASUREMENT_FIELDS, POSTURE_SECTIONS, postureAnswerCount } from '@mp-pilates/shared'
import type { BodyMetricRecord } from '@mp-pilates/shared'
const props = defineProps<{ records: BodyMetricRecord[]; admin?: boolean; userId?: string }>()
defineEmits<{ remove: [id: string] }>()
function measurements(record: BodyMetricRecord) { return BODY_MEASUREMENT_FIELDS.filter(field => record[field.key] != null) }
function completedViews(record: BodyMetricRecord) {
const assessment = record.assessment
if (!assessment) return 0
return Number(Object.values(assessment.plumb).some(Boolean)) + Number(!!assessment.spine.flat || !!assessment.spine.imbalance)
+ POSTURE_SECTIONS.filter(section => section.groups.some(group => group.options.some(option => assessment.answers[option.id]?.length))).length
}
function open(record?: BodyMetricRecord, edit = false) {
const query = [props.admin ? `userId=${encodeURIComponent(props.userId || '')}` : '', record ? `id=${encodeURIComponent(record.id)}` : '', edit ? 'edit=1' : ''].filter(Boolean).join('&')
uni.navigateTo({ url: '/pages/profile/body-assessment?' + query })
}
</script>
<style lang="scss" scoped>
.section-top{display:flex;justify-content:space-between;align-items:center;gap:16rpx;}.eyebrow{display:block;color:#8ba5b3;font-size:18rpx;letter-spacing:4rpx;}.title{display:block;margin-top:12rpx;font-family:'Songti SC','STSong',serif;font-size:36rpx;color:#4a4035;}.add{margin:0;padding:0 22rpx;line-height:76rpx;background:#e8f0f4;color:#587b90;font-size:24rpx;border-radius:18rpx;flex-shrink:0;}.add::after,.text-button::after{border:0;}.intro{display:block;font-size:24rpx;color:#8e8274;line-height:1.9;margin:22rpx 0 30rpx;}.intro>text{display:block;font-size:21rpx;letter-spacing:1rpx;color:#a29485;}.record{padding:28rpx;margin-bottom:24rpx;background:#fffefa;border:1rpx solid #e8e1d7;border-radius:26rpx;animation:unfold .3s ease both;}.record-head{display:flex;align-items:center;justify-content:space-between;}.date{display:flex;align-items:center;gap:20rpx;}.day{font-family:'Baskerville',serif;font-size:64rpx;color:#4a4035;line-height:1;}.month{display:block;font-size:21rpx;color:#9c8e7e;letter-spacing:2rpx;}.record-label{display:block;margin-top:9rpx;font-size:25rpx;color:#5c5145;}.arrow{font-size:24rpx;color:#7ba5be;padding:20rpx 0 20rpx 24rpx;}.scope{display:flex;justify-content:space-between;gap:12rpx;margin:26rpx 0 0;padding:18rpx 20rpx;background:#f1f5f6;border-radius:14rpx;color:#7390a0;font-size:21rpx;}.measurements{display:flex;flex-wrap:wrap;gap:16rpx 22rpx;margin-top:24rpx;font-size:22rpx;color:#9b8d7d;}.value{color:#5d5246;margin-left:8rpx;font-size:26rpx;}.remark{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;white-space:pre-wrap;margin-top:20rpx;font-size:25rpx;line-height:1.8;color:#8a7c6c;}.record-actions{display:flex;justify-content:space-between;align-items:center;border-top:1rpx solid #eee8df;margin-top:24rpx;padding-top:10rpx;}.admin-actions{display:flex;gap:20rpx;}.text-button{background:transparent;padding:0;margin:0;color:#8e8274;font-size:23rpx;line-height:72rpx;}.edit{color:#587b90;}.delete{color:#b09184;}.empty{padding:48rpx 28rpx;text-align:center;border:1rpx dashed #d9d6cc;border-radius:26rpx;color:#a09080;font-size:23rpx;line-height:1.8;}.empty-symbol{font-size:54rpx;font-weight:200;color:#a9bfcc;margin-bottom:12rpx;}.empty-title{display:block;font-size:28rpx;color:#7a6a5a;margin-bottom:12rpx;}.empty-add{display:inline-block;margin-top:28rpx;min-width:200rpx;}@keyframes unfold{from{opacity:0;transform:translateY(10rpx);}to{opacity:1;transform:translateY(0);}}@media(prefers-reduced-motion:reduce){.record{animation:none;}}
</style>

View File

@@ -23,12 +23,13 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
defineProps<{
const props = defineProps<{
title: string
/** Transparent bg with white text — for pages with colored header */
transparent?: boolean
/** Show back arrow (for sub-pages navigated via navigateTo) */
showBack?: boolean
beforeBack?: () => boolean | Promise<boolean>
}>()
const statusBarHeight = ref(0)
@@ -38,7 +39,8 @@ onMounted(() => {
statusBarHeight.value = windowInfo.statusBarHeight ?? 20
})
function handleBack() {
async function handleBack() {
if (props.beforeBack && !await props.beforeBack()) return
const pages = getCurrentPages()
if (pages.length > 1) {

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,33 @@
<template>
<view class="checklist">
<view v-for="(group, index) in section.groups" :key="group.id" class="anatomy-card" :style="{ animationDelay: index * 25 + 'ms' }">
<view class="group-heading"><view class="group-title"><text class="number">{{ String(index + 1).padStart(2, '0') }}</text><text>{{ group.title }}</text></view><text class="status">{{ groupCount(group) ? groupCount(group) + ' 项已记录' : '未检查' }}</text></view>
<view v-for="option in group.options" :key="option.id" class="finding" :class="{ 'finding--selected': answers[option.id]?.length }">
<text class="finding-label">{{ option.label }}</text>
<view class="marks">
<button v-for="mark in option.bilateral ? sideMarks : wholeMarks" :key="mark.key" class="mark" :class="{ selected: answers[option.id]?.includes(mark.key) }" :disabled="readonly" :aria-label="group.title + ',' + option.label + ',' + mark.label + (answers[option.id]?.includes(mark.key) ? ',已选' : ',未选')" @tap="toggle(group, option, mark.key)"><text v-if="answers[option.id]?.includes(mark.key)" class="tick">✓</text>{{ mark.label }}</button>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { togglePostureMark } from '@mp-pilates/shared'
import type { PostureAssessment, PostureGroup, PostureMark, PostureOption, PostureSection } from '@mp-pilates/shared'
const props = defineProps<{ section: PostureSection; answers: PostureAssessment['answers']; readonly?: boolean }>()
const emit = defineEmits<{ change: [answers: PostureAssessment['answers']] }>()
const sideMarks: { key: PostureMark; label: string }[] = [{ key: 'right', label: '右' }, { key: 'left', label: '左' }]
const wholeMarks: { key: PostureMark; label: string }[] = [{ key: 'checked', label: '勾选' }]
function groupCount(group: PostureGroup) { return group.options.reduce((sum, option) => sum + (props.answers[option.id]?.length || 0), 0) }
function toggle(group: PostureGroup, option: PostureOption, mark: PostureMark) {
if (!props.readonly) emit('change', togglePostureMark(props.answers, group, option, mark))
}
</script>
<style lang="scss" scoped>
.anatomy-card{margin-bottom:24rpx;padding:8rpx 26rpx 18rpx;border:1rpx solid #e9e3da;border-radius:24rpx;background:#fffefa;animation:arrive .3s ease both;}
.group-heading{display:flex;align-items:center;justify-content:space-between;gap:14rpx;padding:24rpx 0;border-bottom:1rpx solid #eee9e2;margin-bottom:8rpx;}.group-title{display:flex;align-items:center;gap:16rpx;font-size:29rpx;color:#4a4035;font-weight:500;}.number{font-family:'Baskerville',serif;font-size:28rpx;color:#8ba5b3;}.status{font-size:20rpx;color:#a09080;}
.finding{display:flex;align-items:center;justify-content:space-between;gap:16rpx;min-height:94rpx;padding:6rpx 0;box-sizing:border-box;}.finding-label{flex:1;min-width:0;font-size:25rpx;line-height:1.65;color:#766b5e;}.finding--selected .finding-label{color:#4a4035;}.marks{display:flex;gap:12rpx;flex-shrink:0;}.mark{display:flex;align-items:center;justify-content:center;gap:4rpx;min-width:78rpx;min-height:72rpx;margin:0;padding:0 16rpx;border:1rpx solid #e2e5e3;border-radius:14rpx;background:#f8f8f4;font-size:24rpx;line-height:1.3;color:#9a958c;transition:background .18s,border-color .18s,color .18s,transform .18s;}.mark::after{border:0;}.mark.selected{background:#eaf2f6;border-color:#a9bfcc;color:#51778e;}.mark:active{transform:scale(.95);}.mark[disabled]{opacity:1;}.tick{font-size:20rpx;}@keyframes arrive{from{opacity:0;transform:translateY(12rpx);}to{opacity:1;transform:translateY(0);}}
@media(prefers-reduced-motion:reduce){.anatomy-card{animation:none;}.mark{transition:none;}}
</style>

View File

@@ -75,6 +75,12 @@
"navigationStyle": "custom"
}
},
{
"path": "pages/profile/body-assessment",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/profile/progress",
"style": {

View File

@@ -0,0 +1,196 @@
<template>
<view class="assessment-page" :style="{ paddingTop: navBarHeight + 'px' }">
<CustomNavBar :title="editable ? (recordId ? '编辑体测记录' : '新建体测记录') : '体测记录详情'" show-back :before-back="beforeBack" />
<view v-if="loading" class="state"><view class="skeleton" /><view class="skeleton tall" /><text>正在翻开检查表…</text></view>
<view v-else-if="loadError" class="state"><text>{{ loadError }}</text><button class="primary retry" @tap="load">重新加载</button></view>
<template v-else-if="ready">
<view class="cover">
<view class="cover-top"><text class="eyebrow">POSTURE ASSESSMENT</text><text class="mode">{{ editable ? '填写中 · 各项选填' : '评估记录' }}</text></view>
<text class="cover-title">体态分析指南<text>检查表</text></text>
<view class="identity"><view><text class="caption">会员姓名</text><text class="member-name">{{ nickname || '会员' }}</text></view><view class="date-block"><text class="caption">评估日期</text><picker v-if="editable" mode="date" :disabled="saving" :value="recordDate" :end="today" @change="recordDate = $event.detail.value"><view class="record-date">{{ recordDate.replace(/-/g, ' / ') }} <text>⌄</text></view></picker><text v-else class="record-date">{{ recordDate.replace(/-/g, ' / ') }}</text></view></view>
<view class="cover-bottom"><text>{{ answerCount }} 项检查已记录</text><text>{{ editable ? '无需填满,可随时保存' : '未检查项目保留为空' }}</text></view>
</view>
<view class="navigation" :style="{ top: navBarHeight + 'px' }">
<scroll-view scroll-x :show-scrollbar="false" :scroll-into-view="'step-' + activeStep" :scroll-with-animation="true" class="steps-scroll"><view class="steps"><button v-for="(step, index) in steps" :id="'step-' + index" :key="step.id" class="step" :class="{ active: activeStep === index }" @tap="changeStep(index)"><text class="step-number">{{ String(index + 1).padStart(2, '0') }}</text>{{ step.label }}<view v-if="stepCount(step.id)" class="step-dot" /></button></view></scroll-view>
<view class="progress-line"><view :style="{ width: ((activeStep + 1) / steps.length * 100) + '%' }" /></view>
</view>
<view id="section-content" class="content">
<view class="section-heading"><view><text class="section-kicker">{{ steps[activeStep].english }}</text><text class="section-title">{{ steps[activeStep].title }}</text></view><text class="section-index">{{ String(activeStep + 1).padStart(2, '0') }}<text> / 06</text></text></view>
<template v-if="activeStep === 0">
<text class="guide">身高、体重等均为选填。只记录本次实际测量的数据。</text>
<view class="measurement-card"><view v-for="field in BODY_MEASUREMENT_FIELDS" :key="field.key" class="measurement-field"><text class="field-label">{{ field.label }}<text class="optional">选填</text></text><view class="measurement-input"><input v-if="editable" v-model="measurements[field.key]" :disabled="saving" :type="field.key === 'flexibility' ? 'text' : 'digit'" maxlength="8" placeholder="未测量" placeholder-class="input-placeholder" :aria-label="field.label + ',选填,单位' + field.unit" /><text v-else class="measurement-value">{{ measurements[field.key] || '—' }}</text><text class="unit">{{ field.unit }}</text></view></view></view>
<text class="small-note">坐位体前屈:未触及脚尖可填写负值。</text>
<view class="notes-card"><text class="field-label">评估备注 <text class="optional">选填</text></text><textarea v-if="editable" v-model="remark" :disabled="saving" maxlength="200" placeholder="记录本次观察、测量情况或练习关注点…" :auto-height="true" /><text v-else class="note-copy">{{ remark || '暂无备注' }}</text><text v-if="editable" class="word-count">{{ remark.length }} / 200</text></view>
</template>
<template v-else-if="activeStep === 1">
<text class="guide">依照纸质检查表,观察身体各部分的排列。未检查可留空。</text>
<view v-for="(question, index) in POSTURE_PLUMB_QUESTIONS" :key="question.key" class="question-card"><text class="question-number">0{{ index + 1 }}</text><text class="question-label">{{ question.label }}</text><view class="answer-row"><button v-for="choice in yesNo" :key="choice.value" class="answer" :class="{ selected: assessment.plumb[question.key] === choice.value }" :disabled="!editable || saving" @tap="togglePlumb(question.key, choice.value)"><text class="answer-check">{{ assessment.plumb[question.key] === choice.value ? '✓' : '○' }}</text>{{ choice.label }}</button><text class="unanswered">{{ assessment.plumb[question.key] ? '' : '未检查' }}</text></view></view>
</template>
<template v-else-if="activeSection">
<view class="view-guide"><text class="view-guide-title">两侧均需检查</text><text>{{ editable ? '按实际观察勾选;「右」「左」均指会员自身。再次轻触可取消。' : '「右」「左」均指会员自身;未勾选不代表已确认正常。' }}</text></view>
<PostureChecklist :key="activeSection.id" :section="activeSection" :answers="assessment.answers" :readonly="!editable || saving" @change="assessment.answers = $event" />
<text v-if="activeStep === 3" class="source-note">* 如有必要,可参照后视图加以确认。</text>
</template>
<template v-else-if="activeStep === 5">
<text class="guide">依次从侧面和背后观察,记录检查结果与所在位置。</text>
<view v-for="(question, index) in spineQuestions" :key="question.key" class="question-card"><text class="question-number">0{{ index + 1 }}</text><text class="spine-view">{{ question.view }}</text><text class="question-label">{{ question.label }}</text><view class="answer-row"><button v-for="choice in yesNo" :key="choice.value" class="answer" :class="{ selected: assessment.spine[question.key] === choice.value }" :disabled="!editable || saving" @tap="toggleSpine(question.key, choice.value)"><text class="answer-check">{{ assessment.spine[question.key] === choice.value ? '✓' : '○' }}</text>{{ choice.label }}</button><text class="unanswered">{{ assessment.spine[question.key] ? '' : '未检查' }}</text></view><template v-if="assessment.spine[question.key] === 'yes'"><text class="location-label">在什么位置?<text class="optional">选填</text></text><input v-if="editable" v-model="assessment.spine[question.location]" :disabled="saving" class="location-input" maxlength="200" placeholder="填写观察到的具体位置" /><text v-else class="note-copy">{{ assessment.spine[question.location] || '未记录位置' }}</text></template></view>
<view class="finish-note"><text class="finish-title">每一份观察,都是下一次练习的起点。</text><text>{{ editable ? '检查表可以分次完成。保存后,可随时回到档案继续编辑。' : '这份记录已收录于会员成长档案。' }}</text></view>
</template>
<text v-if="saveError" class="save-error">{{ saveError }}</text>
</view>
<view class="footer"><view class="footer-meta"><text>{{ editable ? (dirty ? '有未保存的修改' : '各项均可选填') : '完整保留本次检查结果' }}</text><text>{{ activeStep + 1 }} / {{ steps.length }}</text></view><view class="footer-buttons"><button class="secondary" :disabled="saving || activeStep === 0" @tap="changeStep(activeStep - 1)">上一步</button><button v-if="activeStep < steps.length - 1" class="secondary next" :disabled="saving" @tap="changeStep(activeStep + 1)">下一项 ›</button><button v-if="editable" class="primary" :loading="saving" :disabled="saving" @tap="save">{{ saving ? '保存中' : '保存记录' }}</button><button v-else-if="canEdit" class="primary" @tap="editable = true">编辑记录</button><button v-else-if="activeStep === steps.length - 1" class="primary" @tap="goBack">返回档案</button></view></view>
</template>
</view>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { onLoad, onUnload } from '@dcloudio/uni-app'
import { BODY_MEASUREMENT_FIELDS, POSTURE_PLUMB_QUESTIONS, POSTURE_SECTIONS, emptyPostureAssessment, postureAnswerCount } from '@mp-pilates/shared'
import type { BodyMetricRecord, BodyMeasurementKey, PostureAssessment, ProgressArchive } from '@mp-pilates/shared'
import CustomNavBar from '../../components/CustomNavBar.vue'
import PostureChecklist from '../../components/PostureChecklist.vue'
import { get, post, put } from '../../utils/request'
import { getSystemLayout } from '../../utils/system'
import { useUserStore } from '../../stores/user'
const navBarHeight = getSystemLayout().navBarHeight
const userStore = useUserStore()
const userId = ref(''), recordId = ref(''), nickname = ref('')
const loading = ref(true), ready = ref(false), editable = ref(false), saving = ref(false), loadError = ref(''), saveError = ref('')
const today = new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10)
const recordDate = ref(today), remark = ref(''), measurements = ref<Partial<Record<BodyMeasurementKey, string>>>({}), assessment = ref<PostureAssessment>(emptyPostureAssessment())
const activeStep = ref(0), initialSnapshot = ref('')
const steps = [
{ id: 'basic', label: '基础', title: '身体基础数据', english: 'BODY MEASUREMENTS' },
{ id: 'plumb', label: '铅垂线', title: '铅垂线', english: 'PLUMB LINE' },
{ id: 'side', label: '侧视', title: '侧视图', english: 'SIDE VIEW' },
{ id: 'front', label: '正面', title: '正面视图', english: 'FRONT VIEW' },
{ id: 'back', label: '后视', title: '后视图', english: 'BACK VIEW' },
{ id: 'spine', label: '脊柱', title: '循序检查脊柱', english: 'SPINAL OBSERVATION' },
]
const yesNo = [{ value: 'yes' as const, label: '是' }, { value: 'no' as const, label: '否' }]
const spineQuestions = [
{ key: 'flat' as const, location: 'flatLocation' as const, view: '从侧面观察', label: '是否有平直部位?' },
{ key: 'imbalance' as const, location: 'imbalanceLocation' as const, view: '从背后观察并触碰检查', label: '是否有失衡部位?' },
]
const canEdit = computed(() => !!userId.value && userStore.isAdmin)
const base = computed(() => userId.value ? `/admin/members/${encodeURIComponent(userId.value)}/progress` : '/user/progress')
const activeSection = computed(() => POSTURE_SECTIONS.find(section => section.id === steps[activeStep.value].id))
const answerCount = computed(() => postureAnswerCount(assessment.value))
const snapshot = () => JSON.stringify({ recordedAt: recordDate.value, measurements: measurements.value, remark: remark.value, assessment: assessment.value })
const dirty = computed(() => ready.value && editable.value && snapshot() !== initialSnapshot.value)
let requestedEdit = false
function stepCount(id: string) {
if (id === 'basic') return Object.values(measurements.value).filter(value => value?.trim()).length + Number(!!remark.value.trim())
if (id === 'plumb') return Object.values(assessment.value.plumb).filter(Boolean).length
if (id === 'spine') return Number(!!assessment.value.spine.flat) + Number(!!assessment.value.spine.imbalance)
return Object.entries(assessment.value.answers).filter(([key, marks]) => key.startsWith(id + '.') && marks.length).length
}
function changeStep(index: number) {
if (saving.value || index < 0 || index >= steps.length) return
activeStep.value = index
saveError.value = ''
// Keep the section navigation in view when moving between long checklists.
uni.pageScrollTo({ scrollTop: 0, duration: 200 })
}
function togglePlumb(key: 'alignment' | 'stacking', value: 'yes' | 'no') {
if (!editable.value || saving.value) return
assessment.value.plumb[key] = assessment.value.plumb[key] === value ? null : value
}
function toggleSpine(key: 'flat' | 'imbalance', value: 'yes' | 'no') {
if (!editable.value || saving.value) return
assessment.value.spine[key] = assessment.value.spine[key] === value ? null : value
if (assessment.value.spine[key] !== 'yes') assessment.value.spine[key === 'flat' ? 'flatLocation' : 'imbalanceLocation'] = ''
}
function message(error: unknown) { return error instanceof Error ? error.message : '操作失败,请重试' }
async function load() {
loading.value = true; ready.value = false; loadError.value = ''
try {
if (userId.value) {
if (!userStore.hasProfile) await userStore.fetchProfile()
if (!userStore.isAdmin) throw new Error('仅馆主可编辑会员体测档案')
}
if (recordId.value) {
const result = await get<{ nickname: string; record: BodyMetricRecord }>(base.value + '/metrics/' + encodeURIComponent(recordId.value))
nickname.value = result.nickname
recordDate.value = result.record.recordedAt.slice(0, 10)
remark.value = result.record.remark || ''
measurements.value = Object.fromEntries(BODY_MEASUREMENT_FIELDS.map(field => [field.key, result.record[field.key] == null ? '' : String(result.record[field.key])]))
// Historical measurements have no posture payload; open them without inventing findings.
assessment.value = result.record.assessment || emptyPostureAssessment()
} else {
if (!canEdit.value) throw new Error('请从会员档案进入体测记录')
nickname.value = (await get<ProgressArchive>(base.value)).nickname
}
editable.value = canEdit.value && (!recordId.value || requestedEdit)
initialSnapshot.value = snapshot()
ready.value = true
} catch (error) { loadError.value = message(error) }
finally { loading.value = false }
}
async function save() {
if (saving.value || !editable.value || !canEdit.value) return
saveError.value = ''
try {
const payload: Record<string, unknown> = { recordedAt: recordDate.value, remark: remark.value, assessment: assessment.value }
let measured = false
for (const field of BODY_MEASUREMENT_FIELDS) {
const raw = measurements.value[field.key]?.trim() || ''
if (!raw) { payload[field.key] = null; continue }
const value = Number(raw)
if (!/^-?\d+(\.\d+)?$/.test(raw) || !Number.isFinite(value) || value < field.min || value > field.max) {
activeStep.value = 0
throw new Error(`${field.label}请填写 ${field.min}~${field.max} ${field.unit} 范围内的数字`)
}
payload[field.key] = value; measured = true
}
if (!measured && !answerCount.value && !remark.value.trim()) throw new Error('请至少记录一项测量、体态检查或评估备注')
saving.value = true
// Freeze the submitted snapshot so a slow request cannot overwrite subsequent input.
const submitted = JSON.parse(JSON.stringify(payload)) as Record<string, unknown>
const record = recordId.value
? await put<BodyMetricRecord>(base.value + '/metrics/' + encodeURIComponent(recordId.value), submitted)
: await post<BodyMetricRecord>(base.value + '/metrics', submitted)
recordId.value = record.id
initialSnapshot.value = snapshot()
editable.value = false
uni.showToast({ title: '体测记录已保存', icon: 'success' })
uni.pageScrollTo({ scrollTop: 0, duration: 200 })
} catch (error) {
saveError.value = message(error)
uni.showToast({ title: saveError.value, icon: 'none' })
} finally { saving.value = false }
}
async function beforeBack() {
if (saving.value) return false
if (!dirty.value) return true
const result = await uni.showModal({ title: '离开检查表?', content: '本次修改尚未保存,离开后将丢失。', confirmText: '继续填写', cancelText: '放弃修改' })
if (!result.cancel) return false
uni.disableAlertBeforeUnload({})
return true
}
async function goBack() { if (await beforeBack()) uni.navigateBack() }
watch(dirty, value => {
if (value) uni.enableAlertBeforeUnload({ message: '体测记录尚未保存,确定离开吗?' })
else uni.disableAlertBeforeUnload({})
})
onLoad(query => { userId.value = String(query?.userId || ''); recordId.value = String(query?.id || ''); requestedEdit = query?.edit === '1'; void load() })
onUnload(() => uni.disableAlertBeforeUnload({}))
</script>
<style lang="scss" scoped>
.assessment-page{min-height:100vh;box-sizing:border-box;background:#faf8f5;color:#4a4035;padding-bottom:calc(220rpx + env(safe-area-inset-bottom));}button::after{border:0;}button{font-weight:400;}button[disabled]{opacity:.5;}.cover{margin:28rpx 32rpx 0;padding:30rpx;background:#eef3f5;border:1rpx solid #dae5ea;border-radius:28rpx;}.cover-top{display:flex;align-items:center;justify-content:space-between;gap:12rpx;}.eyebrow{font-size:18rpx;letter-spacing:3rpx;color:#849eac;}.mode{font-size:20rpx;color:#7a94a1;}.cover-title{display:block;font-family:'Songti SC','STSong',serif;font-size:44rpx;line-height:1.5;margin:22rpx 0 30rpx;letter-spacing:2rpx;}.cover-title>text{font-size:30rpx;margin-left:18rpx;color:#7b8c92;}.identity{display:flex;justify-content:space-between;gap:24rpx;}.identity>view{min-width:0;}.caption{display:block;font-size:20rpx;color:#8e9a9d;margin-bottom:10rpx;}.member-name{display:block;font-size:29rpx;line-height:1.5;overflow-wrap:anywhere;}.date-block{flex-shrink:0;}.record-date{font-size:25rpx;line-height:1.7;color:#657b86;}.record-date>text{margin-left:12rpx;}.cover-bottom{display:flex;justify-content:space-between;gap:12rpx;border-top:1rpx solid #dbe5e9;padding-top:22rpx;margin-top:28rpx;font-size:20rpx;color:#85969d;}
.navigation{position:sticky;z-index:20;background:#faf8f5;padding-top:20rpx;}.steps-scroll{width:100%;}.steps-scroll :deep(.uni-scroll-view){scrollbar-width:none;}.steps-scroll :deep(::-webkit-scrollbar){display:none;width:0;height:0;}.steps{display:flex;gap:10rpx;padding:0 32rpx 20rpx;}.step{position:relative;flex-shrink:0;display:flex;align-items:center;gap:9rpx;margin:0;padding:0 20rpx;height:76rpx;line-height:76rpx;border-radius:16rpx;background:transparent;color:#a39687;font-size:25rpx;transition:background .2s,color .2s;}.step.active{background:#e6eef2;color:#547b91;}.step-number{font-family:'Baskerville',serif;font-size:24rpx;opacity:.65;}.step-dot{position:absolute;right:10rpx;top:10rpx;width:7rpx;height:7rpx;border-radius:50%;background:#8ca58f;}.progress-line{height:3rpx;background:#eee8df;}.progress-line>view{height:100%;background:#a9bfcc;transition:width .25s ease;}
.content{padding:34rpx 32rpx;}.section-heading{display:flex;align-items:center;justify-content:space-between;gap:20rpx;margin:8rpx 0 24rpx;}.section-kicker{display:block;font-size:18rpx;color:#a09080;letter-spacing:3rpx;}.section-title{display:block;margin-top:12rpx;font-family:'Songti SC','STSong',serif;font-size:37rpx;}.section-index{font-family:'Baskerville',serif;font-size:52rpx;color:#b1c2cb;}.section-index>text{font-size:24rpx;color:#c8c4bc;}.guide{display:block;font-size:24rpx;line-height:1.9;color:#958777;margin-bottom:28rpx;}.measurement-card{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:30rpx 24rpx;background:#fffefa;padding:28rpx;border:1rpx solid #e9e3da;border-radius:24rpx;}.field-label{display:block;font-size:25rpx;line-height:1.7;}.optional{font-size:20rpx;color:#b1a596;margin-left:12rpx;}.measurement-input{display:flex;align-items:center;border-bottom:1rpx solid #e9e4db;gap:10rpx;margin-top:12rpx;}.measurement-input input{flex:1;min-width:0;width:100%;height:80rpx;font-size:32rpx;color:#4a4035;}.measurement-value{flex:1;line-height:80rpx;font-size:32rpx;}.unit{font-size:23rpx;color:#b1a595;}.small-note{display:block;color:#a49584;font-size:21rpx;line-height:1.8;margin:18rpx 8rpx 30rpx;}.notes-card{background:#fffefa;border:1rpx solid #e9e3da;border-radius:24rpx;padding:26rpx;}.notes-card textarea{width:100%;min-height:180rpx;line-height:1.8;font-size:26rpx;box-sizing:border-box;margin-top:20rpx;}.word-count{display:block;text-align:right;font-size:20rpx;color:#b2a697;}.note-copy{display:block;font-size:26rpx;color:#8e8070;line-height:1.8;white-space:pre-wrap;overflow-wrap:anywhere;margin-top:16rpx;}
.view-guide{background:#f0eee7;border-left:4rpx solid #d4ccba;padding:20rpx 24rpx;border-radius:0 16rpx 16rpx 0;margin-bottom:28rpx;}.view-guide-title{display:block;font-size:24rpx;color:#80725f;margin-bottom:6rpx;}.view-guide>text:last-child{font-size:22rpx;line-height:1.8;color:#a0927e;}.source-note{display:block;color:#a08e76;font-size:23rpx;line-height:1.8;margin:16rpx 4rpx;}.question-card{background:#fffefa;border:1rpx solid #e9e3da;border-radius:24rpx;padding:28rpx;margin-bottom:24rpx;animation:appear .3s ease both;}.question-number{display:block;font-size:26rpx;font-family:'Baskerville',serif;color:#a9bfcc;letter-spacing:2rpx;margin-bottom:18rpx;}.question-label{display:block;font-size:28rpx;line-height:1.8;}.answer-row{display:flex;align-items:center;gap:16rpx;margin-top:28rpx;}.answer{display:flex;align-items:center;gap:16rpx;margin:0;min-height:80rpx;padding:0 32rpx;line-height:80rpx;border-radius:16rpx;border:1rpx solid #e2e5e3;background:#f8f8f4;color:#9b968c;font-size:26rpx;transition:background .2s;}.answer.selected{background:#eaf2f6;border-color:#a9bfcc;color:#51778e;}.answer[disabled]{opacity:1;}.answer-check{font-size:25rpx;}.unanswered{font-size:21rpx;color:#b1a596;margin-left:auto;}.spine-view{display:block;font-size:23rpx;color:#9a8b77;margin-bottom:10rpx;}.location-label{display:block;font-size:24rpx;color:#8d7e6c;margin-top:30rpx;}.location-input{background:#f4f1eb;box-sizing:border-box;height:90rpx;border-radius:14rpx;padding:0 22rpx;font-size:25rpx;margin-top:16rpx;}.finish-note{padding:28rpx 8rpx;font-size:23rpx;color:#a09280;line-height:1.9;}.finish-title{display:block;font-family:'Songti SC',serif;font-size:29rpx;color:#80725f;margin-bottom:14rpx;}
.footer{position:fixed;z-index:30;bottom:0;left:0;right:0;background:#fffefa;border-top:1rpx solid #eae4db;padding:18rpx 32rpx calc(24rpx + env(safe-area-inset-bottom));box-shadow:0 -8rpx 30rpx rgba(74,64,53,.025);}.footer-meta{display:flex;justify-content:space-between;font-size:20rpx;color:#a49685;line-height:1.8;margin-bottom:14rpx;}.footer-buttons{display:flex;gap:14rpx;}.secondary,.primary{display:flex;align-items:center;justify-content:center;margin:0;line-height:88rpx;min-height:88rpx;border-radius:18rpx;font-size:25rpx;padding:0 24rpx;}.secondary{background:#f2eee7;color:#938571;}.next{flex:1;}.primary{flex:1;background:#7ba5be;color:#fff;}.save-error{display:block;background:#faf0ec;border-radius:14rpx;padding:20rpx;font-size:24rpx;color:#b17764;line-height:1.8;margin-top:20rpx;}.state{padding:70rpx 32rpx;text-align:center;font-size:26rpx;color:#a09080;line-height:1.8;}.retry{margin-top:30rpx;}.skeleton{height:220rpx;border-radius:24rpx;background:#eeeae3;margin-bottom:28rpx;animation:pulse 1.5s ease infinite;}.skeleton.tall{height:460rpx;}@keyframes pulse{50%{opacity:.4;}}@keyframes appear{from{opacity:0;transform:translateY(10rpx);}to{opacity:1;transform:translateY(0);}}@media(prefers-reduced-motion:reduce){.question-card,.skeleton{animation:none;}.step,.progress-line>view,.answer{transition:none;}}
</style>

View File

@@ -0,0 +1,3 @@
ALTER TABLE `body_metrics`
ADD COLUMN `height` DOUBLE NULL,
ADD COLUMN `assessment` JSON NULL;

View File

@@ -384,11 +384,13 @@ model BodyMetric {
id String @id @default(uuid())
userId String @map("user_id")
recordedAt DateTime @db.Date @map("recorded_at")
height Float?
weight Float?
bodyFat Float? @map("body_fat")
waist Float?
hip Float?
flexibility Float?
assessment Json?
remark String @default("") @db.VarChar(200)
operatorId String @map("operator_id")
createdAt DateTime @default(now()) @map("created_at")

View File

@@ -0,0 +1,44 @@
import { INestApplication, UnauthorizedException, ValidationPipe } from '@nestjs/common'
import { Test } from '@nestjs/testing'
import type { ExecutionContext } from '@nestjs/common'
import type { AddressInfo } from 'node:net'
import { JwtAuthGuard } from '../../auth/jwt-auth.guard'
import { RolesGuard } from '../../auth/roles.guard'
import { ShopAdminController, ShopController } from '../shop.controller'
import { ShopService } from '../shop.service'
import { ShopOrderCompatibilityInterceptor } from '../shop-order-compatibility.interceptor'
import { ApiResponseInterceptor } from '../../common/interceptors/api-response.interceptor'
describe('Shop rollout HTTP compatibility', () => {
let app: INestApplication
const order = { id: 'order-1', orderNo: 'SP001', status: 'PAID' }
const service = { orders: jest.fn().mockResolvedValue({ items: [order], total: 1 }), complete: jest.fn().mockResolvedValue({ ...order, status: 'COMPLETED' }) }
async function request(method: string, path: string, role?: string, body?: unknown) {
const address = app.getHttpServer().address() as AddressInfo
return fetch(`http://127.0.0.1:${address.port}${path}`, { method, headers: { 'Content-Type': 'application/json', ...(role ? { 'x-test-role': role } : {}) }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) })
}
beforeAll(async () => {
const module = await Test.createTestingModule({ controllers: [ShopController, ShopAdminController], providers: [{ provide: ShopService, useValue: service }, RolesGuard, ShopOrderCompatibilityInterceptor] })
.overrideGuard(JwtAuthGuard).useValue({ canActivate(context: ExecutionContext) { const req = context.switchToHttp().getRequest(); if (!req.headers['x-test-role']) throw new UnauthorizedException(); req.user = { sub: 'test-operator', role: req.headers['x-test-role'] }; return true } }).compile()
app = module.createNestApplication()
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }))
app.useGlobalInterceptors(new ApiResponseInterceptor())
await app.listen(0, '127.0.0.1')
})
afterAll(async () => { await app.close() })
it.each(['/shop/orders', '/admin/shop/orders'])('supplies the old display field inside the API envelope: %s', async path => {
const response = await request('GET', path, 'ADMIN')
expect(response.status).toBe(200)
expect((await response.json()).data.items[0].pickupCode).toMatch(/^[A-F0-9]{12}$/)
})
it.each([{}, { pickupCode: 'ABCDEF123456' }])('accepts new and already-published administrator request bodies: %j', async body => {
const response = await request('POST', '/admin/shop/orders/order-1/complete', 'ADMIN', body)
expect(response.status).toBe(201)
expect((await response.json()).data.status).toBe('COMPLETED')
expect(service.complete).toHaveBeenCalledWith('order-1', 'test-operator')
})
it.each([undefined, 'MEMBER'])('retains administrator authorization for fulfillment: %s', async role => {
const response = await request('POST', '/admin/shop/orders/order-1/complete', role, {})
expect(response.status).toBe(role ? 403 : 401)
})
})

View File

@@ -0,0 +1,25 @@
import { CallHandler, ExecutionContext } from '@nestjs/common'
import { firstValueFrom, of } from 'rxjs'
import { ShopOrderCompatibilityInterceptor } from '../shop-order-compatibility.interceptor'
describe('Published shop client compatibility', () => {
const interceptor = new ShopOrderCompatibilityInterceptor()
const serialize = (value: unknown) => firstValueFrom(interceptor.intercept({} as ExecutionContext, { handle: () => of(value) } as CallHandler))
it('returns a stable 12-character code for existing and new orders without changing the stored order', async () => {
const order = { id: 'order-1', orderNo: 'SP001', status: 'PAID', amount: 100 }
const result = await serialize(order) as Record<string, unknown>
expect(result.pickupCode).toMatch(/^[A-F0-9]{12}$/)
expect(await serialize(order)).toEqual(result)
expect(order).not.toHaveProperty('pickupCode')
expect(result).toMatchObject(order)
expect((await serialize({ ...order, id: 'order-2' }) as Record<string, unknown>).pickupCode).not.toBe(result.pickupCode)
})
it('preserves pagination while supplying codes to customer and administrator lists', async () => {
const result = await serialize({ items: [{ id: 'order-1', orderNo: 'SP001' }], total: 1, page: 2, limit: 20 }) as { items: { pickupCode: string }[] }
expect(result).toMatchObject({ total: 1, page: 2, limit: 20 })
expect(result.items[0].pickupCode).toMatch(/^[A-F0-9]{12}$/)
})
it.each([null, undefined, { id: 'product', title: '商品' }, { prepayId: 'wx123', paySign: 'signature' }, { orders: 2, amount: 300 }])('leaves non-order responses unchanged: %j', async value => {
expect(await serialize(value)).toBe(value)
})
})

View File

@@ -0,0 +1,24 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'
import { createHash } from 'crypto'
import { map, Observable } from 'rxjs'
/** Keep already-published clients usable until their pickup-code UI is retired. */
function withLegacyPickupCode(value: unknown): unknown {
if (!value || typeof value !== 'object') return value
if (Array.isArray(value)) return value.map(withLegacyPickupCode)
const record = value as Record<string, unknown>
if (typeof record.id === 'string' && typeof record.orderNo === 'string') {
// Display compatibility only: fulfillment is authorized by admin role and PAID status.
// Derive a stable value without retaining the removed database column.
return { ...record, pickupCode: createHash('sha256').update('legacy-pickup:' + record.id).digest('hex').slice(0, 12).toUpperCase() }
}
if (Array.isArray(record.items)) return { ...record, items: record.items.map(withLegacyPickupCode) }
return value
}
@Injectable()
export class ShopOrderCompatibilityInterceptor implements NestInterceptor {
intercept(_context: ExecutionContext, next: CallHandler): Observable<unknown> {
return next.handle().pipe(map(withLegacyPickupCode))
}
}

View File

@@ -10,6 +10,7 @@ import {
Put,
Query,
UseGuards,
UseInterceptors,
} from '@nestjs/common'
import { UserRole } from '@mp-pilates/shared'
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
@@ -18,8 +19,10 @@ import { Roles } from '../auth/roles.decorator'
import { CurrentUser } from '../common/decorators/current-user.decorator'
import { ShopService } from './shop.service'
import { BuyProductDto, ProductDto, ShopQueryDto, StockDto } from './dto/shop.dto'
import { ShopOrderCompatibilityInterceptor } from './shop-order-compatibility.interceptor'
@Controller('shop')
@UseInterceptors(ShopOrderCompatibilityInterceptor)
export class ShopController {
constructor(private readonly shop: ShopService) {}
@Post('wx-notify')
@@ -67,6 +70,7 @@ export class ShopController {
}
@Controller('admin/shop')
@UseInterceptors(ShopOrderCompatibilityInterceptor)
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
export class ShopAdminController {

View File

@@ -3,10 +3,11 @@ import { PrismaModule } from '../prisma/prisma.module'
import { WechatPayService } from '../payment/wechat-pay.service'
import { ShopController, ShopAdminController } from './shop.controller'
import { ShopService } from './shop.service'
import { ShopOrderCompatibilityInterceptor } from './shop-order-compatibility.interceptor'
@Module({
imports: [PrismaModule],
controllers: [ShopController, ShopAdminController],
providers: [ShopService, WechatPayService],
providers: [ShopService, WechatPayService, ShopOrderCompatibilityInterceptor],
exports: [ShopService],
})
export class ShopModule {}

View File

@@ -10,8 +10,9 @@ import { GUARDS_METADATA } from '@nestjs/common/constants'
import { MemberProgressController, AdminMemberProgressController } from '../member-progress.controller'
import { JwtAuthGuard } from '../../auth/jwt-auth.guard'
import { RolesGuard } from '../../auth/roles.guard'
import { emptyPostureAssessment, POSTURE_SECTIONS, togglePostureMark, isPostureAssessment } from '@mp-pilates/shared'
describe('Member progress permissions and measurements', () => {
const db = { user: { findUnique: jest.fn() }, booking: { count: jest.fn(), findFirst: jest.fn() }, lessonSupplement: { aggregate: jest.fn() }, bodyMetric: { findMany: jest.fn(), create: jest.fn(), deleteMany: jest.fn() }, memberNote: { findMany: jest.fn(), create: jest.fn(), deleteMany: jest.fn() }, progressPhoto: { findMany: jest.fn(), findFirst: jest.fn(), updateMany: jest.fn(), update: jest.fn(), create: jest.fn(), delete: jest.fn() } }
const db = { user: { findUnique: jest.fn() }, booking: { count: jest.fn(), findFirst: jest.fn() }, lessonSupplement: { aggregate: jest.fn() }, bodyMetric: { findMany: jest.fn(), findFirst: jest.fn(), create: jest.fn(), updateMany: jest.fn(), deleteMany: jest.fn() }, memberNote: { findMany: jest.fn(), create: jest.fn(), deleteMany: jest.fn() }, progressPhoto: { findMany: jest.fn(), findFirst: jest.fn(), updateMany: jest.fn(), update: jest.fn(), create: jest.fn(), delete: jest.fn() } }
const storage = { signedUrl: jest.fn(), credential: jest.fn(), verify: jest.fn(), removeObject: jest.fn() }
let service: MemberProgressService
beforeEach(() => { jest.resetAllMocks(); service = new MemberProgressService(db as unknown as PrismaService, storage as unknown as ProgressPhotoStorageService); db.user.findUnique.mockResolvedValue({ nickname: '学员' }); db.booking.count.mockResolvedValue(8); db.lessonSupplement.aggregate.mockResolvedValue({ _sum: { quantity: 3 } }); db.bodyMetric.findMany.mockResolvedValue([]); db.memberNote.findMany.mockResolvedValue([]); db.progressPhoto.findMany.mockResolvedValue([]) })
@@ -30,11 +31,71 @@ describe('Member progress permissions and measurements', () => {
await expect(service.addMetric('member', 'admin', { recordedAt: '2025-02-30', weight: 50 })).rejects.toThrow('日期')
await service.addMetric('member', 'admin', { recordedAt: '2025-01-01', flexibility: -3 })
expect(db.bodyMetric.create.mock.calls[0][0].data.flexibility).toBe(-3)
expect(db.bodyMetric.create.mock.calls[0][0].data.weight).toBeUndefined()
expect(db.bodyMetric.create.mock.calls[0][0].data.weight).toBeNull()
})
it('rejects non-finite, out of range and nonnumeric measurements', async () => {
for (const weight of [0, -10, 501, Infinity, '50']) expect((await validate(Object.assign(new BodyMetricDto(), { recordedAt: '2025-01-01', weight }))).length).toBeGreaterThan(0)
})
it('continues to display historical measurements with no posture assessment', async () => {
const historical = { id: 'legacy', recordedAt: '2025-01-01', weight: 50, bodyFat: null, flexibility: -3, remark: '首次测量', height: null, assessment: null }
db.bodyMetric.findMany.mockResolvedValue([historical])
db.bodyMetric.findFirst.mockResolvedValue(historical)
expect((await service.archive('member')).metrics).toEqual([historical])
expect(await service.metric('member', 'legacy')).toEqual({ nickname: '学员', record: historical })
expect(db.bodyMetric.findFirst).toHaveBeenCalledWith({ where: { id: 'legacy', userId: 'member' } })
})
it('accepts legacy clients without new fields, posture-only records and notes-only records', async () => {
await service.addMetric('member', 'admin', { recordedAt: '2025-01-01', weight: 50 })
expect(db.bodyMetric.create.mock.calls[0][0].data).toMatchObject({ weight: 50, height: null, assessment: emptyPostureAssessment() })
const assessment = emptyPostureAssessment()
assessment.plumb.alignment = 'no'
await service.addMetric('member', 'admin', { recordedAt: '2025-01-01', assessment })
expect(db.bodyMetric.create.mock.calls[1][0].data).toMatchObject({ weight: null, assessment })
await service.addMetric('member', 'admin', { recordedAt: '2025-01-01', remark: '观察肩部排列' })
expect(db.bodyMetric.create.mock.calls[2][0].data.remark).toBe('观察肩部排列')
})
it('allows empty optional measurements, including explicit null when clearing an existing value', async () => {
expect(await validate(Object.assign(new BodyMetricDto(), { recordedAt: '2025-01-01', height: null, weight: null }))).toHaveLength(0)
for (const height of [0, 251, Infinity, '170']) expect((await validate(Object.assign(new BodyMetricDto(), { recordedAt: '2025-01-01', height }))).length).toBeGreaterThan(0)
})
it('updates the existing member record in place and clears removed measurements and findings', async () => {
const assessment = emptyPostureAssessment()
assessment.spine.flat = 'yes'; assessment.spine.flatLocation = '下胸椎'
db.bodyMetric.updateMany.mockResolvedValue({ count: 1 })
db.bodyMetric.findFirst.mockResolvedValue({ id: 'existing', assessment })
await service.updateMetric('member', 'existing', 'admin', { recordedAt: '2025-01-02', weight: null, assessment })
expect(db.bodyMetric.updateMany).toHaveBeenCalledWith({ where: { id: 'existing', userId: 'member' }, data: expect.objectContaining({ weight: null, height: null, assessment, operatorId: 'admin', recordedAt: new Date('2025-01-02T00:00:00.000Z') }) })
expect(db.bodyMetric.create).not.toHaveBeenCalled()
})
it('cannot read or edit another member record and rejects invalid edits before writing', async () => {
db.bodyMetric.findFirst.mockResolvedValue(null)
db.bodyMetric.updateMany.mockResolvedValue({ count: 0 })
await expect(service.metric('other', 'existing')).rejects.toThrow('不存在')
await expect(service.updateMetric('other', 'existing', 'admin', { recordedAt: '2025-01-01', weight: 50 })).rejects.toThrow('不存在')
expect(db.bodyMetric.updateMany.mock.calls[0][0].where).toEqual({ id: 'existing', userId: 'other' })
db.bodyMetric.updateMany.mockClear()
await expect(service.updateMetric('member', 'existing', 'admin', { recordedAt: '2025-02-30', weight: 50 })).rejects.toThrow('日期')
await expect(service.updateMetric('member', 'existing', 'admin', { recordedAt: '2025-01-01' })).rejects.toThrow('至少填写')
expect(db.bodyMetric.updateMany).not.toHaveBeenCalled()
})
it('rejects malformed, unknown, wrong-side and contradictory posture payloads', async () => {
const badPayloads = [
[], { ...emptyPostureAssessment(), version: 2 },
{ ...emptyPostureAssessment(), answers: { unknown: ['right'] } },
{ ...emptyPostureAssessment(), answers: { 'side.ankle.neutral': ['checked'] } },
{ ...emptyPostureAssessment(), answers: { 'side.ankle.neutral': ['right', 'right'] } },
{ ...emptyPostureAssessment(), answers: { 'side.ankle.neutral': ['right'], 'side.ankle.plantar': ['right'] } },
{ ...emptyPostureAssessment(), plumb: { alignment: true, stacking: null } },
{ ...emptyPostureAssessment(), spine: { flat: 'no', flatLocation: 'stale location', imbalance: null, imbalanceLocation: '' } },
{ ...emptyPostureAssessment(), spine: { flat: 'yes', flatLocation: 'x'.repeat(201), imbalance: null, imbalanceLocation: '' } },
]
for (const assessment of badPayloads) {
const dto = Object.assign(new BodyMetricDto(), { recordedAt: '2025-01-01', assessment })
expect((await validate(dto)).length).toBeGreaterThan(0)
await expect(service.addMetric('member', 'admin', dto)).rejects.toThrow('体态检查内容无效')
}
expect(db.bodyMetric.create).not.toHaveBeenCalled()
})
it('only annotates the target member completed booking', async () => {
db.booking.findFirst.mockResolvedValue(null)
await expect(service.addNote('member', 'admin', { content: 'note', shared: false, bookingId: 'foreign' })).rejects.toThrow('只能批注')
@@ -86,6 +147,42 @@ describe('Member progress permissions and measurements', () => {
})
})
describe('Posture checklist selection behavior', () => {
const allGroups = POSTURE_SECTIONS.flatMap(section => section.groups)
it('keeps left and right independent while replacing contradictory findings on the same side', () => {
const group = allGroups.find(group => group.id === 'side.ankle')!
const [neutral, plantar, dorsal] = group.options
let answers = togglePostureMark({}, group, neutral, 'left')
answers = togglePostureMark(answers, group, plantar, 'right')
expect(answers).toEqual({ [neutral.id]: ['left'], [plantar.id]: ['right'] })
answers = togglePostureMark(answers, group, dorsal, 'right')
expect(answers).toEqual({ [neutral.id]: ['left'], [dorsal.id]: ['right'] })
answers = togglePostureMark(answers, group, dorsal, 'right')
expect(answers).toEqual({ [neutral.id]: ['left'] })
})
it('allows scapula findings in different planes and excludes neutral only on the affected side', () => {
const group = allGroups.find(group => group.id === 'back.scapula')!
const option = (id: string) => group.options.find(option => option.id === group.id + '.' + id)!
let answers = togglePostureMark({}, group, option('neutral'), 'left')
answers = togglePostureMark(answers, group, option('elevated'), 'right')
answers = togglePostureMark(answers, group, option('upward'), 'right')
answers = togglePostureMark(answers, group, option('winging'), 'right')
expect(Object.keys(answers)).toHaveLength(4)
expect(isPostureAssessment({ ...emptyPostureAssessment(), answers })).toBe(true)
answers = togglePostureMark(answers, group, option('neutral'), 'right')
expect(answers).toEqual({ [option('neutral').id]: ['left', 'right'] })
})
it('clears bilateral abnormalities when whole-body neutral is selected', () => {
const group = allGroups.find(group => group.id === 'front.head')!
const [clockwise, , neutral, tilt] = group.options
let answers = togglePostureMark({}, group, clockwise, 'checked')
answers = togglePostureMark(answers, group, tilt, 'right')
expect(isPostureAssessment({ ...emptyPostureAssessment(), answers })).toBe(true)
answers = togglePostureMark(answers, group, neutral, 'checked')
expect(answers).toEqual({ [neutral.id]: ['checked'] })
})
})
describe('Member progress authorization', () => {
it('requires authentication on member and admin progress controllers', () => {
expect(Reflect.getMetadata(GUARDS_METADATA, MemberProgressController)).toContain(JwtAuthGuard)
@@ -99,4 +196,12 @@ describe('Member progress authorization', () => {
expect(guard.canActivate(context('MEMBER'))).toBe(false)
expect(guard.canActivate(context('ADMIN'))).toBe(true)
})
it('restricts record editing to admins and exposes no member write route', () => {
const handler = AdminMemberProgressController.prototype.updateMetric
const guard = new RolesGuard(new Reflector())
const context = (role: string) => ({ getHandler: () => handler, getClass: () => AdminMemberProgressController, switchToHttp: () => ({ getRequest: () => ({ user: { role } }) }) }) as unknown as ExecutionContext
expect(guard.canActivate(context('MEMBER'))).toBe(false)
expect(guard.canActivate(context('ADMIN'))).toBe(true)
expect('updateMetric' in MemberProgressController.prototype).toBe(false)
})
})

View File

@@ -1,11 +1,14 @@
import { IsBoolean, IsDateString, IsNumber, IsOptional, IsString, Max, MaxLength, Min, MinLength, Matches } from 'class-validator'
import { IsBoolean, IsDateString, IsNumber, IsOptional, IsString, Max, MaxLength, Min, MinLength, Matches, ValidateBy } from 'class-validator'
import { isPostureAssessment, PostureAssessment } from '@mp-pilates/shared'
export class BodyMetricDto {
@IsDateString() @Matches(/^\d{4}-\d{2}-\d{2}$/) recordedAt!: string
@IsOptional() @IsNumber() @Min(1) @Max(500) weight?: number
@IsOptional() @IsNumber() @Min(1) @Max(75) bodyFat?: number
@IsOptional() @IsNumber() @Min(10) @Max(300) waist?: number
@IsOptional() @IsNumber() @Min(10) @Max(300) hip?: number
@IsOptional() @IsNumber() @Min(-50) @Max(100) flexibility?: number
@IsOptional() @IsNumber() @Min(30) @Max(250) height?: number | null
@IsOptional() @IsNumber() @Min(1) @Max(500) weight?: number | null
@IsOptional() @IsNumber() @Min(1) @Max(75) bodyFat?: number | null
@IsOptional() @IsNumber() @Min(10) @Max(300) waist?: number | null
@IsOptional() @IsNumber() @Min(10) @Max(300) hip?: number | null
@IsOptional() @IsNumber() @Min(-50) @Max(100) flexibility?: number | null
@IsOptional() @ValidateBy({ name: 'postureAssessment', validator: { validate: isPostureAssessment, defaultMessage: () => '体态检查内容无效,请检查选项及左右侧标记' } }) assessment?: PostureAssessment | null
@IsOptional() @IsString() @MaxLength(200) remark?: string
}
export class MemberNoteDto {

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Delete, Get, Param, Post, UseGuards } from '@nestjs/common'
import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards } from '@nestjs/common'
import { UserRole } from '@mp-pilates/shared'
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
import { RolesGuard } from '../auth/roles.guard'
@@ -10,6 +10,7 @@ import { BodyMetricDto, MemberNoteDto, PhotoConsentDto, ProgressPhotoDto } from
export class MemberProgressController {
constructor(private readonly service: MemberProgressService) {}
@Get() archive(@CurrentUser('sub') userId: string) { return this.service.archive(userId) }
@Get('metrics/:id') metric(@CurrentUser('sub') userId: string, @Param('id') id: string) { return this.service.metric(userId, id) }
@Post('photos/:id/consent') consent(@CurrentUser('sub') userId: string, @Param('id') id: string, @Body() dto: PhotoConsentDto) { return this.service.consent(userId, id, dto.consent) }
@Get('photos/:id/url') url(@CurrentUser('sub') userId: string, @Param('id') id: string) { return this.service.photoUrl(userId, id) }
@Delete('photos/:id') removePhoto(@CurrentUser('sub') userId: string, @Param('id') id: string) { return this.service.remove(userId, 'photos', id) }
@@ -19,6 +20,8 @@ export class AdminMemberProgressController {
constructor(private readonly service: MemberProgressService) {}
@Get() archive(@Param('userId') userId: string) { return this.service.archive(userId, true) }
@Post('metrics') metric(@Param('userId') userId: string, @CurrentUser('sub') operatorId: string, @Body() dto: BodyMetricDto) { return this.service.addMetric(userId, operatorId, dto) }
@Get('metrics/:id') metricDetail(@Param('userId') userId: string, @Param('id') id: string) { return this.service.metric(userId, id) }
@Put('metrics/:id') updateMetric(@Param('userId') userId: string, @Param('id') id: string, @CurrentUser('sub') operatorId: string, @Body() dto: BodyMetricDto) { return this.service.updateMetric(userId, id, operatorId, dto) }
@Post('notes') note(@Param('userId') userId: string, @CurrentUser('sub') operatorId: string, @Body() dto: MemberNoteDto) { return this.service.addNote(userId, operatorId, dto) }
@Delete(':kind/:id') remove(@Param('userId') userId: string, @Param('kind') kind: string, @Param('id') id: string) { return this.service.remove(userId, kind, id) }
@Post('photos/upload') upload(@Param('userId') userId: string, @CurrentUser('sub') operatorId: string, @Body() dto: ProgressPhotoDto) { return this.service.upload(userId, operatorId, dto) }

View File

@@ -2,6 +2,8 @@ import { BadRequestException, ForbiddenException, Injectable, NotFoundException
import { PrismaService } from '../prisma/prisma.service'
import { BodyMetricDto, MemberNoteDto, ProgressPhotoDto } from './dto/member-progress.dto'
import { ProgressPhotoStorageService } from './progress-photo-storage.service'
import { BODY_MEASUREMENT_FIELDS, emptyPostureAssessment, isPostureAssessment, postureAnswerCount } from '@mp-pilates/shared'
import { Prisma } from '@prisma/client'
@Injectable()
export class MemberProgressService {
constructor(private readonly prisma: PrismaService, private readonly storage: ProgressPhotoStorageService) {}
@@ -29,8 +31,26 @@ export class MemberProgressService {
}
async addMetric(userId: string, operatorId: string, dto: BodyMetricDto) {
await this.member(userId)
if ([dto.weight, dto.bodyFat, dto.waist, dto.hip, dto.flexibility].every(v => v == null)) throw new BadRequestException('请至少填写一项体测数据')
return this.prisma.bodyMetric.create({ data: { ...dto, userId, operatorId, recordedAt: this.date(dto.recordedAt) } })
return this.prisma.bodyMetric.create({ data: { ...this.metricData(dto), userId, operatorId } })
}
async metric(userId: string, id: string) {
const member = await this.member(userId)
const record = await this.prisma.bodyMetric.findFirst({ where: { id, userId } })
if (!record) throw new NotFoundException('体测记录不存在')
return { nickname: member.nickname, record }
}
async updateMetric(userId: string, id: string, operatorId: string, dto: BodyMetricDto) {
const data = this.metricData(dto)
const result = await this.prisma.bodyMetric.updateMany({ where: { id, userId }, data: { ...data, operatorId } })
if (!result.count) throw new NotFoundException('体测记录不存在')
return this.prisma.bodyMetric.findFirst({ where: { id, userId } })
}
private metricData(dto: BodyMetricDto) {
const assessment = dto.assessment ?? emptyPostureAssessment()
if (!isPostureAssessment(assessment)) throw new BadRequestException('体态检查内容无效')
const measurements = Object.fromEntries(BODY_MEASUREMENT_FIELDS.map(field => [field.key, dto[field.key] ?? null]))
if (BODY_MEASUREMENT_FIELDS.every(field => dto[field.key] == null) && !postureAnswerCount(assessment) && !dto.remark?.trim()) throw new BadRequestException('请至少填写一项体测数据、体态检查或评估备注')
return { ...measurements, recordedAt: this.date(dto.recordedAt), remark: dto.remark?.trim() || '', assessment: assessment as unknown as Prisma.InputJsonValue }
}
async addNote(userId: string, operatorId: string, dto: MemberNoteDto) {
await this.member(userId)

View File

@@ -137,6 +137,7 @@ export type {
} from './types/index'
export * from './types/member-care'
export * from './posture-assessment'
export * from './types/body-portrait'
export * from './types/shop'

View File

@@ -0,0 +1,125 @@
/** Transcribed from the member's photographed「体态分析指南 检查表」. */
export type PostureMark = 'checked' | 'right' | 'left'
export type AssessmentAnswer = 'yes' | 'no' | null
export interface PostureAssessment {
version: 1
answers: Record<string, PostureMark[]>
plumb: { alignment: AssessmentAnswer; stacking: AssessmentAnswer }
spine: { flat: AssessmentAnswer; flatLocation: string; imbalance: AssessmentAnswer; imbalanceLocation: string }
}
export interface PostureOption {
id: string
label: string
bilateral: boolean
neutral: boolean
axis: string
}
export interface PostureGroup { id: string; title: string; options: PostureOption[] }
export interface PostureSection { id: string; title: string; subtitle: string; groups: PostureGroup[] }
// The last two flags retain the paper's right/left columns and anatomical planes.
type OptionInput = [id: string, label: string, bilateral?: boolean, neutral?: boolean, axis?: string]
function group(id: string, title: string, rows: OptionInput[]): PostureGroup {
return { id, title, options: rows.map(([key, label, bilateral = false, neutral = false, axis = 'position']) => ({ id: `${id}.${key}`, label, bilateral, neutral, axis })) }
}
const bilateralPosition: OptionInput[] = [['neutral', '中立位', true, true], ['flexion', '屈曲', true], ['extension', '伸展', true]]
export const POSTURE_PLUMB_QUESTIONS = [
{ key: 'alignment' as const, label: '身体各部分是否与铅垂线相符(头部、上身、肩部、骨盆和膝部)' },
{ key: 'stacking' as const, label: '头部、胸部和骨盆对于彼此是否为排列对齐?' },
]
export const POSTURE_SECTIONS: PostureSection[] = [
{ id: 'side', title: '侧视图', subtitle: '两侧均需检查', groups: [
group('side.ankle', '踝关节', [['neutral', '中立位', true, true], ['plantar', '跖屈', true], ['dorsal', '背屈', true]]),
group('side.knee', '膝部', [['neutral', '中立位', true, true], ['hyperextension', '过度伸展', true], ['flexion', '屈曲', true]]),
group('side.hip', '髋关节', bilateralPosition),
group('side.pelvis', '骨盆', [['neutral', '中立位', true, true], ['anterior', '骨盆前倾', true], ['posterior', '骨盆后倾', true]]),
group('side.lumbar', '腰椎', [['neutral', '中立位', false, true], ['flat', '平直前凸曲度减小'], ['extension', '过度伸展前凸曲度增大']]),
group('side.lowerThoracic', '下胸椎', [['neutral', '中立位', false, true], ['flat', '平直后凸曲度减小'], ['flexion', '过度屈曲后凸曲度增大']]),
group('side.upperThoracic', '上胸椎', [['neutral', '中立位', false, true], ['flat', '平直后凸曲度减小'], ['flexion', '过度屈曲后凸曲度增大']]),
group('side.cervical', '颈椎', [['neutral', '中立位', false, true], ['flat', '平直前凸曲度减小'], ['extension', '过度伸展前凸曲度增大']]),
group('side.head', '头部', [['neutral', '中立位', false, true], ['forward', '前伸'], ['retracted', '后缩']]),
] },
{ id: 'front', title: '正面视图', subtitle: '两侧均需检查', groups: [
group('front.foot', '足部', [['neutral', '中立位', true, true], ['supination', '内翻/旋后', true], ['pronation', '外翻/旋前', true]]),
group('front.knee', '膝部', [['neutral', '中立位', false, true], ['valgus', 'X型腿膝外翻'], ['varus', 'O型腿膝内翻']]),
group('front.pelvis', '骨盆', [['level', '水平', true, true], ['elevated', '上提', true, false, 'height'], ['clockwise', '顺时针偏转', false, false, 'rotation'], ['counterclockwise', '逆时针偏转', false, false, 'rotation']]),
group('front.ribcage', '肋骨架', [['neutral', '中立位', false, true], ['elevated', '上提', true, false, 'height'], ['shift', '平移', true, false, 'shift'], ['clockwise', '顺时针偏转', false, false, 'rotation'], ['counterclockwise', '逆时针偏转', false, false, 'rotation']]),
group('front.shoulder', '肩部', [['level', '水平', false, true], ['elevated', '上提', true], ['depressed', '下沉', true]]),
group('front.head', '头部', [['clockwise', '顺时针偏转', false, false, 'rotation'], ['counterclockwise', '逆时针偏转', false, false, 'rotation'], ['neutral', '中立位', false, true], ['tilt', '倾斜', true, false, 'tilt'], ['shift', '平移', true, false, 'shift']]),
] },
{ id: 'back', title: '后视图', subtitle: '两侧均需检查', groups: [
group('back.foot', '足部', [['neutral', '中立位', true, true], ['supination', '内翻/旋后', true], ['pronation', '外翻/旋前', true]]),
group('back.femur', '股骨', [['neutral', '中立位', true, true], ['internal', '内旋', true], ['external', '外旋', true]]),
group('back.pelvis', '骨盆', [['level', '水平', false, true], ['elevated', '上提', true, false, 'height'], ['clockwise', '顺时针偏转', false, false, 'rotation'], ['counterclockwise', '逆时针偏转', false, false, 'rotation']]),
group('back.scapula', '肩胛骨', [['neutral', '中立位', true, true], ['protracted', '前引', true, false, 'translation'], ['retracted', '后缩', true, false, 'translation'], ['elevated', '上提', true, false, 'height'], ['depressed', '下沉', true, false, 'height'], ['upward', '上旋', true, false, 'rotation'], ['downward', '下旋', true, false, 'rotation'], ['winging', '外翻', true, false, 'winging'], ['anterior', '前倾', true, false, 'tilt']]),
group('back.humerus', '肱骨', [['neutral', '中立位', true, true], ['internal', '内旋', true]]),
] },
]
export const BODY_MEASUREMENT_FIELDS = [
{ key: 'height', label: '身高', unit: 'cm', min: 30, max: 250 },
{ key: 'weight', label: '体重', unit: 'kg', min: 1, max: 500 },
{ key: 'bodyFat', label: '体脂率', unit: '%', min: 1, max: 75 },
{ key: 'waist', label: '腰围', unit: 'cm', min: 10, max: 300 },
{ key: 'hip', label: '臀围', unit: 'cm', min: 10, max: 300 },
{ key: 'flexibility', label: '坐位体前屈', unit: 'cm', min: -50, max: 100 },
] as const
export type BodyMeasurementKey = typeof BODY_MEASUREMENT_FIELDS[number]['key']
export function emptyPostureAssessment(): PostureAssessment {
return { version: 1, answers: {}, plumb: { alignment: null, stacking: null }, spine: { flat: null, flatLocation: '', imbalance: null, imbalanceLocation: '' } }
}
/** Select per side; neutral and opposing findings in the same plane exclude each other. */
export function togglePostureMark(answers: PostureAssessment['answers'], group: PostureGroup, option: PostureOption, mark: PostureMark): PostureAssessment['answers'] {
const next = Object.fromEntries(Object.entries(answers).map(([id, marks]) => [id, [...marks]]))
if (next[option.id]?.includes(mark)) {
next[option.id] = next[option.id].filter(value => value !== mark)
} else {
for (const other of group.options) {
if (other.id === option.id) continue
const conflicts = option.neutral || other.neutral || option.axis === other.axis
if (!conflicts) continue
// Whole-body rows affect both sides; otherwise preserve the opposite side.
next[other.id] = (next[other.id] || []).filter(value => option.bilateral && other.bilateral && value !== mark)
}
next[option.id] = [...(next[option.id] || []), mark]
}
return Object.fromEntries(Object.entries(next).filter(([, marks]) => marks.length))
}
const isObject = (value: unknown): value is Record<string, unknown> => !!value && typeof value === 'object' && !Array.isArray(value)
const exactKeys = (value: Record<string, unknown>, keys: string[]) => Object.keys(value).length === keys.length && keys.every(key => Object.prototype.hasOwnProperty.call(value, key))
const isAnswer = (value: unknown) => value === null || value === 'yes' || value === 'no'
export function isPostureAssessment(value: unknown): value is PostureAssessment {
if (!isObject(value) || !exactKeys(value, ['version', 'answers', 'plumb', 'spine']) || value.version !== 1 || !isObject(value.answers) || !isObject(value.plumb) || !isObject(value.spine)) return false
const { answers, plumb, spine } = value
if (!exactKeys(plumb, ['alignment', 'stacking']) || !Object.values(plumb).every(isAnswer)) return false
if (!exactKeys(spine, ['flat', 'flatLocation', 'imbalance', 'imbalanceLocation']) || !isAnswer(spine.flat) || !isAnswer(spine.imbalance)) return false
if (![spine.flatLocation, spine.imbalanceLocation].every(text => typeof text === 'string' && text.length <= 200)) return false
if ((spine.flat !== 'yes' && spine.flatLocation !== '') || (spine.imbalance !== 'yes' && spine.imbalanceLocation !== '')) return false
const groups = POSTURE_SECTIONS.flatMap(section => section.groups)
const options = groups.flatMap(group => group.options)
for (const [key, marks] of Object.entries(answers)) {
const option = options.find(option => option.id === key)
if (!option || !Array.isArray(marks) || !marks.length || new Set(marks).size !== marks.length || !marks.every(mark => option.bilateral ? mark === 'right' || mark === 'left' : mark === 'checked')) return false
}
// Reject contradictory submissions even when the client is bypassed.
for (const group of groups) {
const selected = group.options.filter(option => Array.isArray(answers[option.id]))
for (let i = 0; i < selected.length; i++) for (let j = i + 1; j < selected.length; j++) {
const a = selected[i], b = selected[j]
if (!(a.neutral || b.neutral || a.axis === b.axis)) continue
if (!a.bilateral || !b.bilateral || (answers[a.id] as string[]).some(mark => (answers[b.id] as string[]).includes(mark))) return false
}
}
return true
}
export function postureAnswerCount(assessment: PostureAssessment | null | undefined): number {
if (!assessment) return 0
return Object.values(assessment.answers).reduce((sum, marks) => sum + marks.length, 0)
+ Object.values(assessment.plumb).filter(Boolean).length
+ [assessment.spine.flat, assessment.spine.imbalance].filter(Boolean).length
}

View File

@@ -9,7 +9,8 @@ export interface ReviewSummary {
month: string; count: number; average: number | null; nps: number | null; npsCount: number
}
export interface BodyMetricRecord {
id: string; recordedAt: string; weight: number | null; bodyFat: number | null; waist: number | null; hip: number | null; flexibility: number | null; remark: string
id: string; recordedAt: string; height?: number | null; weight: number | null; bodyFat: number | null; waist: number | null; hip: number | null; flexibility: number | null; remark: string
assessment?: import('../posture-assessment').PostureAssessment | null
}
export interface MemberNoteRecord {
id: string; content: string; shared: boolean; createdAt: string; bookingId: string | null

View File

@@ -24,6 +24,8 @@ export interface ShopOrder {
contactName: string
contactPhone: string
status: ShopOrderStatus
/** @deprecated Response-only bridge for published clients; not used for authorization. */
pickupCode?: string
expiresAt: string
createdAt: string
paidAt: string | null