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

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