Files
bodyWeight/miniprogram/pages/detailedReport/detailedReport.ts
T
17792275749andClaude Opus 4.7 7a1e8942a9 feat(detailedReport): 基础指标完善评分单位、健康评价等级与BMI状态
- 健康评分补"分"单位
- 健康评价展示等级文案(如"亚健康")替代数值
- BMI名称后跟体型状态胶囊,颜色取底部刻度色

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-02 10:36:23 +08:00

406 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// pages/detailedReport/detailedReport.ts
import { get } from '../../utils/request/index'
/** 身体成分单项指标(bodyDataList 元素) */
interface LefuBodyDatum {
/** 指标 key,如 ppBMI、ppFat、ppMuscleKgLeftArm */
bodyParamKey: string
/** 指标中文名称 */
bodyParamName: string
/** UI 渲染颜色数组 */
colorArray?: string[]
/** 当前标准等级 */
currentStandard?: number
/** 当前测量值(字符串) */
currentValue: string
/** 指标介绍 */
introduction?: string
/** 标准范围数组 */
standardArray?: number[]
/** 当前等级标题 */
standardTitle?: string
/** 标准标题数组 */
standardTitleArray?: string[]
/** 标准颜色 */
standColor?: string
/** 标准评估结果 */
standeEvaluation?: string
/** 标准建议 */
standSuggestion?: string
/** 单位 */
unit?: string
}
/** 用户信息 */
interface MeasureUserInfo {
userId?: string
realname?: string
sex?: number
height?: number
birthday?: string
avatar?: string | null
[key: string]: any
}
/** 测量历史详情 */
interface MeasureHistoryVO {
recordId?: string
/** 身高(cm) */
height?: number
/** 体重(kg) */
weight?: number
/** 与上次体重增减量(kg) */
weightChange?: number
bmi?: number
/** BMI 类型:消瘦/正常/超重/肥胖 */
bmiType?: string
standardWeight?: number
fatRate?: number
fatValue?: number
basalMetabolism?: number
/** 测量时间 */
examTime?: string
sn?: string
userInfo?: MeasureUserInfo
/** 电极类型:0=4电极,1/5=8电极 */
scaleType?: number
bodyDataList?: LefuBodyDatum[]
}
/** 查询测量历史详情(recordId 不传时返回最近一次) */
const getMeasureHistory = (params: { userId?: string; recordId?: string }) =>
get<MeasureHistoryVO>('weighingScale/v3/getMeasureHistory', params)
/** 指标行(身体成分/脂肪分析等卡片) */
interface MetricRow {
label: string
value: string
unit: string
status: string
color: string
}
/** 基础指标网格单元 */
interface BasicCell {
label: string
/** 名称后跟的状态文字(如 BMI 的"超重" */
labelExtra: string
/** 状态文字背景色 */
labelExtraColor: string
value: string
unit: string
}
/** 躯干/四肢节段单元(同时含质量与占比) */
interface BodyPart {
label: string
massValue: string
massUnit: string
rateValue: string
rateUnit: string
}
/** 指标卡片 */
interface MetricCard {
title: string
rows: MetricRow[]
}
/** 躯干/四肢卡片(每行 1~2 个节段单元) */
interface BodyPartsCard {
title: string
rows: BodyPart[][]
}
const EMPTY_BASIC_CELL: BasicCell = { label: '', labelExtra: '', labelExtraColor: '', value: '', unit: '' }
const DEFAULT_STATUS_COLOR = '#B6B6B6'
/** BMI 体型状态对应的刻度色 */
const BMI_TYPE_COLOR: Record<string, string> = {
消瘦: '#1385FA',
正常: '#2AC79F',
超重: '#FFA151',
肥胖: '#F24439',
}
/** 将 bodyDataList 转为 key -> 指标 的映射 */
const toMap = (list: LefuBodyDatum[] = []): Record<string, LefuBodyDatum> => {
const map: Record<string, LefuBodyDatum> = {}
list.forEach((d) => {
if (d && d.bodyParamKey) map[d.bodyParamKey] = d
})
return map
}
/** 按 key 取指标并转成行,缺失返回 null */
const pick = (map: Record<string, LefuBodyDatum>, key: string): MetricRow | null => {
const d = map[key]
if (!d) return null
return {
label: d.bodyParamName || key,
value: d.currentValue ?? '',
unit: d.unit || '',
status: d.standardTitle || '',
color: d.standColor || DEFAULT_STATUS_COLOR,
}
}
/** 按一组 key 构建行,自动跳过缺失项 */
const buildRows = (map: Record<string, LefuBodyDatum>, keys: string[]): MetricRow[] =>
keys.map((k) => pick(map, k)).filter((r): r is MetricRow => r !== null)
/** 构建节段单元(质量 + 占比) */
const buildPart = (
map: Record<string, LefuBodyDatum>,
label: string,
massKey: string,
rateKey: string,
): BodyPart => ({
label,
massValue: map[massKey]?.currentValue ?? '--',
massUnit: map[massKey]?.unit ?? 'kg',
rateValue: map[rateKey]?.currentValue ?? '--',
rateUnit: map[rateKey]?.unit ?? '%',
})
/** 体重拆分整数 + 小数 */
const splitWeight = (weight: number): { int: string; decimal: string } => {
const str = weight.toFixed(2)
const [int, dec] = str.split('.')
return { int, decimal: `.${dec}` }
}
/**
* 刻度轨道结构(rpx):屏宽 750 - basicIndicators 左右 padding 30*2 - 左右 border 4*2 = 682。
* BLOCK_GAP 与 less 中 basicIndicatorsTrack 的 gap: 6rpx 保持同步。
*/
const TRACK_WIDTH = 682
const BLOCK_GAP = 6
const BLOCK_WIDTH = (TRACK_WIDTH - BLOCK_GAP * 3) / 4
/** 根据 BMI 计算刻度圆圈圆心位置(百分比),对齐消瘦/正常/超重/肥胖四段分界 18.5/24/28,并计入色块间隙 */
const calcBmiLeft = (bmi?: number): number => {
if (bmi == null || isNaN(bmi)) return 0
let left: number
if (bmi < 18.5) {
left = (bmi / 18.5) * BLOCK_WIDTH
} else if (bmi < 24) {
left = BLOCK_WIDTH + BLOCK_GAP + ((bmi - 18.5) / (24 - 18.5)) * BLOCK_WIDTH
} else if (bmi < 28) {
left = BLOCK_WIDTH * 2 + BLOCK_GAP * 2 + ((bmi - 24) / (28 - 24)) * BLOCK_WIDTH
} else {
left = BLOCK_WIDTH * 3 + BLOCK_GAP * 3 + ((bmi - 28) / (40 - 28)) * BLOCK_WIDTH
}
return Math.min(Math.max(left / TRACK_WIDTH, 0), 1) * 100
}
Page({
data: {
// 导航栏背景色,根据滚动位置动态变化
navBgColor: 'rgba(255,255,255,0)',
header: {
date: '',
weightInt: '--',
weightDecimal: '',
bmiText: '',
changeText: '',
},
bmiCircleLeft: 0,
basicRows: [] as BasicCell[][],
bodyComposition: { title: '身体成分', rows: [] } as MetricCard,
fatAnalysis: { title: '脂肪分析', rows: [] } as MetricCard,
physique: { title: '体态管理', rows: [] } as MetricCard,
weightControl: { title: '体重管控', rows: [] } as MetricCard,
// 以下卡片为 8 电极专属
cellWater: { title: '细胞液分析', rows: [] } as MetricCard,
advancedComposition: { title: '进阶成分', rows: [] } as MetricCard,
bodyFat: { title: '躯干/四肢脂肪', rows: [] } as BodyPartsCard,
bodyMuscle: { title: '躯干/四肢肌肉', rows: [] } as BodyPartsCard,
},
onLoad(options: Record<string, string | undefined>) {
const recordId = options.recordId || ''
const userInfo = (wx.getStorageSync('userInfo') || {}) as Record<string, any>
const userId = options.userId || userInfo.userId || ''
this.fetchHistory(userId, recordId)
},
async fetchHistory(userId: string, recordId: string) {
if (!userId && !recordId) return
try {
const res = await getMeasureHistory({ userId, recordId })
this.applyResult(res.result)
} catch {
// 请求层已弹错误提示
}
},
applyResult(data: MeasureHistoryVO) {
const map = toMap(data.bodyDataList)
const isEight = data.scaleType === 1 || data.scaleType === 5
// 头部
const hasWeight = data.weight != null && !isNaN(data.weight)
const weight = hasWeight ? splitWeight(data.weight as number) : { int: '--', decimal: '' }
const wc = data.weightChange
const changeText =
wc != null && wc !== 0 ? `较上次${wc > 0 ? '+' : ''}${wc}kg` : ''
const header = {
date: data.examTime || '--',
weightInt: weight.int,
weightDecimal: weight.decimal,
bmiText: data.bmiType ? `BMI${data.bmiType}` : '',
changeText,
}
// 基础指标(2 行 x 3 列,第 6 格留空保持布局)
const basicKeys = ['ppWeightKg', 'ppBMI', 'ppFat', 'ppBodyScore', 'ppBodyHealth']
const basicCells: BasicCell[] = basicKeys.map((k) => {
const d = map[k]
if (!d) return { ...EMPTY_BASIC_CELL }
const isScore = k === 'ppBodyScore'
const isHealth = k === 'ppBodyHealth'
const isBmi = k === 'ppBMI'
return {
label: d.bodyParamName || k,
// BMI 名称后跟体型状态(消瘦/正常/超重/肥胖)
labelExtra: isBmi ? data.bmiType || '' : '',
labelExtraColor: isBmi ? BMI_TYPE_COLOR[data.bmiType || ''] || DEFAULT_STATUS_COLOR : '',
// 健康评价展示等级文案(如"亚健康"),其余展示测量数值
value: isHealth ? d.standardTitle || d.currentValue || '' : d.currentValue || '',
// 健康评分补"分"
unit: isScore ? d.unit || '分' : isHealth ? '' : d.unit || '',
}
})
basicCells.push({ ...EMPTY_BASIC_CELL })
const basicRows = [basicCells.slice(0, 3), basicCells.slice(3, 6)]
// 身体成分
const bodyComposition = {
title: '身体成分',
rows: buildRows(map, [
'ppWaterPercentage',
'ppWaterKg',
'ppProteinPercentage',
'ppProteinKg',
'ppBodyfatKg',
'ppMuscleKg',
'ppMusclePercentage',
'ppBodySkeletalKg',
'ppBodySkeletal',
'ppBoneKg',
]),
}
// 脂肪分析
const fatAnalysis = {
title: '脂肪分析',
rows: buildRows(map, [
'ppVisceralFat',
'ppBodyFatSubCutPercentage',
'ppBodyFatSubCutKg',
]),
}
// 体态管理(理想体重 key 随电极类型变化,推测腰臀比为 8 电极专属)
const idealWeightKey = isEight ? 'ppBodyStandardWeightKg' : 'ppIdealWeightKg'
const physiqueKeys = [idealWeightKey, 'ppLoseFatWeightKg', 'ppBodyAge', 'ppBodyType']
if (isEight) physiqueKeys.push('ppWHR')
const physique = { title: '体态管理', rows: buildRows(map, physiqueKeys) }
// 体重管控
const weightControl = {
title: '体重管控',
rows: buildRows(map, [
'ppControlWeightKg',
'ppFatControlKg',
'ppBodyMuscleControl',
]),
}
// 8 电极专属卡片
const cellWater = isEight
? { title: '细胞液分析', rows: buildRows(map, ['ppWaterICWKg', 'ppWaterECWKg']) }
: { title: '细胞液分析', rows: [] }
const advancedComposition = isEight
? {
title: '进阶成分',
rows: buildRows(map, ['ppMineralKg', 'ppCellMassKg', 'ppSmi']),
}
: { title: '进阶成分', rows: [] }
// 节段两列一行:[[左臂,右臂],[左腿,右腿],[躯干]]
const chunkParts = (parts: BodyPart[]): BodyPart[][] => [
[parts[0], parts[1]],
[parts[2], parts[3]],
[parts[4]],
]
const fatPartLabels: [string, string, string][] = [
['左臂', 'ppBodyFatKgLeftArm', 'ppBodyFatRateLeftArm'],
['右臂', 'ppBodyFatKgRightArm', 'ppBodyFatRateRightArm'],
['左腿', 'ppBodyFatKgLeftLeg', 'ppBodyFatRateLeftLeg'],
['右腿', 'ppBodyFatKgRightLeg', 'ppBodyFatRateRightLeg'],
['躯干', 'ppBodyFatKgTrunk', 'ppBodyFatRateTrunk'],
]
const bodyFat = isEight
? {
title: '躯干/四肢脂肪',
rows: chunkParts(
fatPartLabels.map(([label, massKey, rateKey]) =>
buildPart(map, label, massKey, rateKey),
),
),
}
: { title: '躯干/四肢脂肪', rows: [] }
const musclePartLabels: [string, string, string][] = [
['左臂', 'ppMuscleKgLeftArm', 'ppMuscleRateLeftArm'],
['右臂', 'ppMuscleKgRightArm', 'ppMuscleRateRightArm'],
['左腿', 'ppMuscleKgLeftLeg', 'ppMuscleRateLeftLeg'],
['右腿', 'ppMuscleKgRightLeg', 'ppMuscleRateRightLeg'],
['躯干', 'ppMuscleKgTrunk', 'ppMuscleRateTrunk'],
]
const bodyMuscle = isEight
? {
title: '躯干/四肢肌肉',
rows: chunkParts(
musclePartLabels.map(([label, massKey, rateKey]) =>
buildPart(map, label, massKey, rateKey),
),
),
}
: { title: '躯干/四肢肌肉', rows: [] }
this.setData({
header,
bmiCircleLeft: calcBmiLeft(data.bmi),
basicRows,
bodyComposition,
fatAnalysis,
physique,
weightControl,
cellWater,
advancedComposition,
bodyFat,
bodyMuscle,
})
},
// 滚动时导航栏背景渐显
onScroll(e: WechatMiniprogram.ScrollViewScroll) {
const scrollTop = e.detail.scrollTop
const opacity = Math.min(Math.max(scrollTop / 300, 0), 1)
this.setData({
navBgColor: `rgba(255,255,255,${opacity})`,
})
},
})