From 89c23058f05bf2eb9ae5aec593db357fdcbd83d3 Mon Sep 17 00:00:00 2001 From: Cxx0822 <1556464090@qq.com> Date: Fri, 4 Sep 2026 15:55:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96AI=E6=8A=A5=E5=91=8A?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Record/AiResultPopup.vue | 107 +++++- src/pages.json | 4 + src/pages/record/index.vue | 13 +- src/pages/report/index.vue | 165 +++++++++ src/store/record.ts | 56 ++- src/type/record.ts | 37 ++ src/type/report.ts | 146 ++++++++ src/utils/report.ts | 462 ++++++++++++++++-------- 8 files changed, 830 insertions(+), 160 deletions(-) create mode 100644 src/pages/report/index.vue create mode 100644 src/type/report.ts diff --git a/src/components/Record/AiResultPopup.vue b/src/components/Record/AiResultPopup.vue index a9a4c99..9872c61 100644 --- a/src/components/Record/AiResultPopup.vue +++ b/src/components/Record/AiResultPopup.vue @@ -6,14 +6,105 @@ :close-on-click-modal="false" custom-style="border-radius: 32rpx 32rpx 0 0; padding: 40rpx 32rpx" > - - + + + + + + + + + + + + + + + + + + + + + + + + + {{ section.hasAbnormal ? '需关注' : '正常' }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 取消 - + 分享 @@ -23,6 +114,7 @@ diff --git a/src/pages.json b/src/pages.json index a867877..5c1aefc 100644 --- a/src/pages.json +++ b/src/pages.json @@ -15,6 +15,10 @@ { "path": "pages/user/index", "style": { "navigationBarTitleText": "我的" } + }, + { + "path": "pages/report/index", + "style": { "navigationBarTitleText": "健康报告" } } ], "tabBar": { diff --git a/src/pages/record/index.vue b/src/pages/record/index.vue index 8a6cd37..a31014d 100644 --- a/src/pages/record/index.vue +++ b/src/pages/record/index.vue @@ -121,7 +121,7 @@ import { useDialog } from '@wot-ui/ui' import AiResultPopup from "@/components/Record/AiResultPopup.vue"; import { useRecordStore } from '@/store/record' import { getProfile } from "@/utils/profile"; -import { preprocessHealthData } from "@/utils/ai"; +import { generateMonthlyReport } from "@/utils/report"; const { onShareAppMessage } = share() onShareAppMessage() @@ -256,7 +256,7 @@ const AIClick = () => { recordStore.showAIButton = false const profile = getProfile() if (profile.name && profile.gender && profile.height && profile.birthYear) { - const preprocess = JSON.stringify(preprocessHealthData(getProfile(), getMonthRecords(selectMonth.value))) + const report = JSON.stringify(generateMonthlyReport(getMonthRecords(selectMonth.value), selectMonth.value, profile)) dialog .confirm({ title: `开始分析${selectMonth.value}记录数据,本次分析将消耗 100 积分,是否继续?`, @@ -264,14 +264,15 @@ const AIClick = () => { .then(async () => { try { recordStore.isAnalyzing = true - recordStore.aiResult = '' + // recordStore.aiResult = {} + // const data = await getReport(report) + // recordStore.aiResult = JSON.parse(data) - const data = await getReport(preprocess) - recordStore.aiResult = data + // TODO 结果上传到服务器 返回报告id recordStore.showAIResultPopup = true } catch (error) { recordStore.showAIButton = true - recordStore.aiResult = '' + recordStore.aiResult = {} recordStore.showAIResultPopup = false await dialog.alert({ title: '提示', diff --git a/src/pages/report/index.vue b/src/pages/report/index.vue new file mode 100644 index 0000000..23accf7 --- /dev/null +++ b/src/pages/report/index.vue @@ -0,0 +1,165 @@ + + + + diff --git a/src/store/record.ts b/src/store/record.ts index 793fa2a..58048fa 100644 --- a/src/store/record.ts +++ b/src/store/record.ts @@ -1,6 +1,7 @@ import { defineStore } from 'pinia' import { addBloodPressure, addBloodSugar, addSleep, addSport, addTemperature, addWeight } from '@/utils/record' -import {getDefaultRecordValue} from "@/utils/profile"; +import { getDefaultRecordValue } from "@/utils/profile"; +import type { IAiReport } from "@/type/report"; export const useRecordStore = defineStore('record', { state: () => ({ @@ -53,7 +54,58 @@ export const useRecordStore = defineStore('record', { isAnalyzing: false, showAIResultPopup: false, showAIButton: true, - aiResult: '' + aiResult: { + title: "Cxx的健康月报 · 2026年9月", + greeting: "Cxx你好!这个月你完成了3天的健康数据记录,虽然覆盖还不算全面,但坚持记录就是好的开始。当前体重约69.9kg,BMI约24.2,略高于标准上限,结合你的均衡饮食目标,可以先从调整饮食和增加活动入手。本月亮点是睡眠达标且空腹血糖正常,但血压呈现升高趋势,需要多加留意。", + sections: [ + { + type: "weight", + emoji: "⚖️", + title: "体重", + hasAbnormal: false, + data: "体重从70kg降至69.7kg,整体平稳,平均69.9kg,波动幅度仅0.3kg。根据身高170cm计算,BMI约24.2,略超标准范围上限。", + suggestion: "结合均衡饮食目标,继续保持清淡饮食,减少高油高糖食物,并配合适量运动。" + }, + { + type: "sleep", + emoji: "😴", + title: "睡眠", + hasAbnormal: false, + data: "本月记录1次睡眠,时长8小时,达标率100%,睡眠质量表现很好。", + suggestion: "保持规律的作息时间,尽量在固定时间就寝和起床,让好习惯延续下去。" + }, + { + type: "sport", + emoji: "🏃", + title: "运动", + hasAbnormal: true, + data: "运动记录7次,实际运动1天,总时长30分钟,运动频率14.3%,最长连续不运动2天。考虑到你久坐少动的状态,运动量还需要提升。", + suggestion: "建议每周累计至少150分钟中等强度运动,可以从快走、跑步等开始,每小时起身活动5分钟。" + }, + { + type: "bloodPressure", + emoji: "❤️", + title: "血压", + hasAbnormal: true, + data: "收缩压从120升至145mmHg,舒张压从70升至97mmHg,平均血压约132.5/83.5mmHg,异常率50%;心率平均80次/分。血压升高明显,需要重视。", + suggestion: "请尽快复测血压并咨询医生,同时注意低盐饮食、避免熬夜、减轻压力,并坚持每日监测。" + }, + { + type: "bloodSugar", + emoji: "🩸", + title: "血糖", + hasAbnormal: false, + data: "空腹血糖5.4mmol/L,处于正常范围,餐前、餐后及睡前血糖暂无记录。", + suggestion: "继续保持均衡饮食,控制精制糖和甜食摄入,后续可完善餐后血糖记录。" + } + ], + tips: [ + "血压是本月的重点预警项:高压和低压均有明显上升,建议尽快就医复测,并在医生指导下规律监测。", + "结合久坐少动的工作特点,每坐1小时就起身活动几分钟,下班后安排30分钟快走或慢跑,逐渐增加运动频率。", + "把均衡饮食落到实处:减少盐和油,多吃蔬菜水果,少吃腌制食品和外卖,帮助体重和血压一起改善。" + ], + ending: "健康管理是一场长跑,不必追求完美,只要持续用心就好。下个月继续一起关注血压和运动,期待你更好的状态!" + } as IAiReport }), actions: { openWeight() { diff --git a/src/type/record.ts b/src/type/record.ts index 76c50a2..0a7e049 100644 --- a/src/type/record.ts +++ b/src/type/record.ts @@ -6,6 +6,43 @@ export interface IHealthRecord { [key: string]: any } +export interface WeightRecord extends IHealthRecord { + type: 'weight' + value: number +} + +export interface SleepRecord extends IHealthRecord { + type: 'sleep' + startTime: number + endTime: number + duration: number +} + +export interface SportRecord extends IHealthRecord { + type: 'sport' + category: string + duration: number +} + +export interface BloodPressureRecord extends IHealthRecord { + type: 'bloodPressure' + systolic: number + diastolic: number + heartRate: number +} + +export interface BloodSugarRecord extends IHealthRecord { + type: 'bloodSugar' + value: number + period: '空腹' | '餐前' | '餐后2h' | '睡前' | '随机' +} + +export interface TemperatureRecord extends IHealthRecord { + type: 'temperature' + value: number + location: string +} + export interface IRecordMeta { icon: string label: string diff --git a/src/type/report.ts b/src/type/report.ts new file mode 100644 index 0000000..935ae9f --- /dev/null +++ b/src/type/report.ts @@ -0,0 +1,146 @@ +import type {IProfile} from "@/type/profile"; + +export interface SummaryReport { + totalDays: number + recordDays: number + coverageRate: number +} + +export interface WeightReport { + totalRecords: number + startValue: number + endValue: number + maxValue: number + minValue: number + avgValue: number + netChange: number + fluctuation: number +} + +export interface SleepReport { + totalRecords: number + maxDuration: number + minDuration: number + avgDuration: number + qualifiedDays: number + qualifiedRate: number +} + +export interface SportReport { + totalRecords: number + sportDays: number + frequency: number + totalDuration: number + avgDailyDuration: number + maxSingleDuration: number + categoryDistribution: Record + maxConsecutiveNoSport: number +} + +export interface VitalReport { + totalRecords: number + startValue: number + endValue: number + maxValue: number + minValue: number + avgValue: number + netChange: number + normalDays?: number + abnormalDays?: number + abnormalRate?: number + maxConsecutiveAbnormal?: number +} + +export interface BloodPressureReport { + totalRecords: number + systolic: VitalReport & { + normalDays: number + abnormalDays: number + abnormalRate: number + maxConsecutiveAbnormal: number + } + diastolic: VitalReport & { + normalDays: number + abnormalDays: number + abnormalRate: number + maxConsecutiveAbnormal: number + } + heartRate: VitalReport +} + +export interface BloodSugarPeriodReport { + totalRecords: number + startValue: number + endValue: number + maxValue: number + minValue: number + avgValue: number + netChange: number + normalCount: number + abnormalCount: number + abnormalRate: number + maxConsecutiveAbnormal: number +} + +export interface BloodSugarReport { + fasting: BloodSugarPeriodReport + premeal: BloodSugarPeriodReport + postmeal: BloodSugarPeriodReport + bedtime: BloodSugarPeriodReport +} + +export interface TemperatureReport { + totalRecords: number + startValue: number + endValue: number + maxValue: number + minValue: number + avgValue: number + netChange: number + normalDays: number + lowFeverDays: number + highFeverDays: number + maxConsecutiveAbnormal: number +} + +export interface MonthlyReport { + userInfo: IProfile, + month: string + summary: SummaryReport + weight: WeightReport | null + sleep: SleepReport | null + sport: SportReport | null + bloodPressure: BloodPressureReport | null + bloodSugar: BloodSugarReport | null + temperature: TemperatureReport | null +} + +/** 报告指标项 */ +export interface IReportSection { + /** 指标类型:weight | sleep | sport | bloodPressure | bloodSugar | temperature */ + type: string + /** 指标对应的 Emoji 图标 */ + emoji: string + /** 指标标题,如:体重 */ + title: string + /** 是否存在异常 */ + hasAbnormal: boolean + /** 指标数据描述,包含数值、变化、趋势等 */ + data: string + /** 建议内容,1-2句话 */ + suggestion: string +} + +/** 完整报告数据 */ +export interface IAiReport { + /** 报告标题,如:Cxx的健康月报 · 2026年9月 */ + title: string + /** 开篇问候语,包含称呼、鼓励语、本月总结、BMI分析等,2-4句话 */ + greeting: string + /** 各指标详情列表 */ + sections: IReportSection[] + /** 下月健康建议列表,2-3条 */ + tips: string[] + /** 结尾鼓励语,1-2句话 */ + ending: string +} diff --git a/src/utils/report.ts b/src/utils/report.ts index 8bb6275..585d37f 100644 --- a/src/utils/report.ts +++ b/src/utils/report.ts @@ -1,179 +1,343 @@ -import type { IHealthRecord } from "@/type/record"; +/** + * 月度健康报告生成工具 + * 使用方法: + * const report = generateMonthlyReport(records, '2026-09', 30) + */ +import type { + BloodPressureRecord, + BloodSugarRecord, + IHealthRecord, + SleepRecord, + SportRecord, TemperatureRecord, + WeightRecord +} from "@/type/record"; +import type { + BloodPressureReport, + BloodSugarReport, MonthlyReport, + SleepReport, + SportReport, TemperatureReport, + VitalReport, + WeightReport +} from "@/type/report"; +import type {IProfile} from "@/type/profile"; -const avg = (arr) => arr.reduce((s, v) => s + v, 0) / arr.length -const max = (arr) => Math.max(...arr) -const min = (arr) => Math.min(...arr) -const round = (v, d = 1) => Math.round(v * Math.pow(10, d)) / Math.pow(10, d) +function getDateStringFromTimestamp(timestamp: number): string { + const d = new Date(timestamp) + const year = d.getFullYear() + const month = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + return year + '-' + month + '-' + day +} +function sortByTime(arr: T[]): T[] { + return [...arr].sort((a, b) => a.timestamp - b.timestamp) +} -function computeWeight(records: IHealthRecord[], height: number) { - const list = records.filter(r => r.type === 'weight').sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0)) +function avg(arr: number[]): number { + if (!arr || arr.length === 0) return 0 + return Math.round((arr.reduce((s, v) => s + v, 0) / arr.length) * 10) / 10 +} - if (!list.length) return undefined +function max(arr: number[]): number { + if (!arr || arr.length === 0) return 0 + return Math.max(...arr) +} - const values = list.map(r => r.value) - const first = values[0] - const last = values[values.length - 1] - const h = height / 100 - const firstBMI = first / (h * h) - const lastBMI = last / (h * h) +function min(arr: number[]): number { + if (!arr || arr.length === 0) return 0 + return Math.min(...arr) +} - // 理想体重范围:BMI 18.5 ~ 23.9 - const idealMin = round(18.5 * h * h, 1) - const idealMax = round(23.9 * h * h, 1) +function round1(v: number): number { + return Math.round(v * 10) / 10 +} - // BMI 分类 - let bmiCategory = '' - if (lastBMI < 18.5) bmiCategory = '偏瘦' - else if (lastBMI < 24.0) bmiCategory = '正常' - else if (lastBMI < 28.0) bmiCategory = '超重' - else bmiCategory = '肥胖' +function percent(part: number, total: number): number { + if (total === 0) return 0 + return Math.round((part / total) * 1000) / 10 +} +function netChange(start: number, end: number): number { + return round1(end - start) +} + +function maxConsecutive(arr: number[], isAbnormal: (v: number) => boolean): number { + let maxCount = 0 + let current = 0 + arr.forEach(v => { + if (isAbnormal(v)) { + current++ + maxCount = Math.max(maxCount, current) + } else { + current = 0 + } + }) + return maxCount +} + +function calcVitalReport( + values: number[], + isAbnormal: (v: number) => boolean +): VitalReport & { + normalDays: number + abnormalDays: number + abnormalRate: number + maxConsecutiveAbnormal: number +} { + const len = values.length + if (len === 0) { + return { + totalRecords: 0, + startValue: 0, + endValue: 0, + maxValue: 0, + minValue: 0, + avgValue: 0, + netChange: 0, + normalDays: 0, + abnormalDays: 0, + abnormalRate: 0, + maxConsecutiveAbnormal: 0, + } + } + const abnormalValues = values.filter(v => isAbnormal(v)) return { - count: list.length, - firstValue: first, - lastValue: last, - change: round(last - first, 1), - avg: round(avg(values), 1), - max: max(values), - min: min(values), - fluctuation: round(max(values) - min(values), 1), - firstBMI: round(firstBMI, 1), - lastBMI: round(lastBMI, 1), - avgBMI: round(avg(values) / (h * h), 1), - bmiChange: round(lastBMI - firstBMI, 1), - bmiCategory, - idealWeightRange: [idealMin, idealMax] + totalRecords: len, + startValue: values[0], + endValue: values[len - 1], + maxValue: max(values), + minValue: min(values), + avgValue: avg(values), + netChange: netChange(values[0], values[len - 1]), + normalDays: len - abnormalValues.length, + abnormalDays: abnormalValues.length, + abnormalRate: percent(abnormalValues.length, len), + maxConsecutiveAbnormal: maxConsecutive(values, isAbnormal), } } -function computeSleep(records: IHealthRecord[]) { - const list = records.filter(r => r.type === 'sleep') - if (!list.length) return undefined +// ============ 各指标计算函数 ============ - const hours = list.map(r => r.duration / 60) - const bedtimes = list.map(r => { - const d = new Date(r.startTime || r.timestamp || 0) - return d.getHours() * 60 + d.getMinutes() +function calcWeightReport(records: IHealthRecord[]): WeightReport | null { + const filtered = records.filter(r => r.type === 'weight') as WeightRecord[] + if (filtered.length === 0) return null + const sorted = sortByTime(filtered) + const values = sorted.map(r => r.value) + return { + totalRecords: values.length, + startValue: values[0], + endValue: values[values.length - 1], + maxValue: max(values), + minValue: min(values), + avgValue: avg(values), + netChange: netChange(values[0], values[values.length - 1]), + fluctuation: round1(max(values) - min(values)), + } +} + +function calcSleepReport(records: IHealthRecord[]): SleepReport | null { + const filtered = records.filter(r => r.type === 'sleep') as SleepRecord[] + if (filtered.length === 0) return null + const sorted = sortByTime(filtered) + const values = sorted.map(r => r.duration) + const qualified = values.filter(v => v >= 7) + return { + totalRecords: values.length, + maxDuration: max(values), + minDuration: min(values), + avgDuration: avg(values), + qualifiedDays: qualified.length, + qualifiedRate: percent(qualified.length, values.length), + } +} + +function calcSportReport(records: IHealthRecord[]): SportReport | null { + const filtered = records.filter(r => r.type === 'sport') as SportRecord[] + if (filtered.length === 0) return null + const sorted = sortByTime(filtered) + const durations = sorted.map(r => r.duration) + const totalDur = durations.reduce((s, v) => s + v, 0) + + const categoryDist: Record = {} + sorted.forEach(r => { + const cat = r.category || '其他' + categoryDist[cat] = (categoryDist[cat] || 0) + 1 }) - return { - count: list.length, - avgDurationHours: round(avg(hours), 1), - maxDurationHours: round(max(hours), 1), - minDurationHours: round(min(hours), 1), - enoughCount: hours.filter(v => v >= 7).length, - insufficientCount: hours.filter(v => v < 6).length, - avgBedtimeMinutes: round(avg(bedtimes), 0) - } -} - -// ==================== 4. 运动 ==================== - -function computeSport(records:IHealthRecord[]) { - const list = records.filter(r => r.type === 'sport') - if (!list.length) return undefined - - const totalMinutes = list.reduce((s, r) => s + (r.duration || 0), 0) - const byCategory = {} - list.forEach(r => { byCategory[r.category] = (byCategory[r.category] || 0) + 1 }) - - const activeDays = new Set(list.map(r => r.date || '')).size - - return { - count: list.length, - totalMinutes, - totalHours: round(totalMinutes / 60, 1), - avgMinutes: round(totalMinutes / list.length, 0), - activeDays, - byCategory, - meetsRecommendation: totalMinutes >= 150 - } -} - -// ==================== 5. 血压 ==================== - -function computeBloodPressure(records:IHealthRecord[]) { - const list = records.filter(r => r.type === 'bloodPressure') - if (!list.length) return undefined - - const sys = list.map(r => r.systolic) - const dia = list.map(r => r.diastolic) - const hr = list.map(r => r.heartRate) - const abnormal = list.filter(r => r.systolic >= 140 || r.diastolic >= 90).length - - return { - count: list.length, - avgSys: round(avg(sys), 0), - avgDia: round(avg(dia), 0), - avgHR: round(avg(hr), 0), - maxSys: max(sys), - maxDia: max(dia), - minSys: min(sys), - minDia: min(dia), - abnormalCount: abnormal, - normalRate: round(1 - abnormal / list.length, 3) - } -} - -// ==================== 6. 血糖 ==================== - -function computeBloodSugar(records:IHealthRecord[]) { - const list = records.filter(r => r.type === 'bloodSugar') - if (!list.length) return undefined - - const periodStats = {} - const groups = {} - - list.forEach(r => { - groups[r.period] = groups[r.period] || [] - groups[r.period].push(r.value) + // 最长连续不运动天数 + const allDates = new Set() + records.forEach(r => { + allDates.add(getDateStringFromTimestamp(r.timestamp)) }) - - Object.keys(groups).forEach(period => { - const values = groups[period] - const threshold = period === '空腹' ? 7.0 : 11.1 - periodStats[period] = { - count: values.length, - avg: round(avg(values), 1), - abnormal: values.filter(v => v > threshold).length + const sportDates = new Set() + sorted.forEach(r => { + sportDates.add(getDateStringFromTimestamp(r.timestamp)) + }) + const sortedDates = [...allDates].sort((a, b) => new Date(a).getTime() - new Date(b).getTime()) + let maxNoSport = 0 + let currentNoSport = 0 + sortedDates.forEach(date => { + if (sportDates.has(date)) { + currentNoSport = 0 + } else { + currentNoSport++ + maxNoSport = Math.max(maxNoSport, currentNoSport) } }) - const allValues = list.map(r => r.value) - return { - count: list.length, - periodStats, - fluctuation: round(max(allValues) - min(allValues), 1), - overallStable: round(max(allValues) - min(allValues), 1) < 3 + totalRecords: records.length, + sportDays: sorted.length, + frequency: percent(sorted.length, records.length), + totalDuration: totalDur, + avgDailyDuration: round1(totalDur / records.length), + maxSingleDuration: max(durations), + categoryDistribution: categoryDist, + maxConsecutiveNoSport: maxNoSport, } } +function calcBloodPressureReport(records: IHealthRecord[]): BloodPressureReport | null { + const filtered = records.filter(r => r.type === 'bloodPressure') as BloodPressureRecord[] + if (filtered.length === 0) return null + const sorted = sortByTime(filtered) -function computeTemperature(records: IHealthRecord[]) { - const list = records.filter(r => r.type === 'temperature') - if (!list.length) return undefined - - const values = list.map(r => r.value) + const systolicValues = sorted.map(r => r.systolic) + const diastolicValues = sorted.map(r => r.diastolic) + const heartRateValues = sorted.map(r => r.heartRate) return { - count: list.length, - avg: round(avg(values), 1), - max: max(values), - min: min(values), - abnormalCount: values.filter(v => v >= 37.3).length, - highFeverCount: values.filter(v => v >= 38.5).length + totalRecords: sorted.length, + systolic: calcVitalReport(systolicValues, v => v >= 140), + diastolic: calcVitalReport(diastolicValues, v => v >= 90), + heartRate: { + totalRecords: heartRateValues.length, + startValue: heartRateValues[0] || 0, + endValue: heartRateValues[heartRateValues.length - 1] || 0, + maxValue: max(heartRateValues), + minValue: min(heartRateValues), + avgValue: avg(heartRateValues), + netChange: netChange(heartRateValues[0] || 0, heartRateValues[heartRateValues.length - 1] || 0), + }, } } -export function generateMonthlyReport(records: IHealthRecord[], height: number) { - return { - weight: computeWeight(records, height), - sleep: computeSleep(records), - sport: computeSport(records), - bloodPressure: computeBloodPressure(records), - bloodSugar: computeBloodSugar(records), - temperature: computeTemperature(records) +function calcBloodSugarReport(records: IHealthRecord[]): BloodSugarReport | null { + const filtered = records.filter(r => r.type === 'bloodSugar') as BloodSugarRecord[] + if (filtered.length === 0) return null + + const periodMap: Record = { + '空腹': 'fasting', + '餐前': 'premeal', + '餐后2h': 'postmeal', + '睡前': 'bedtime', } -} \ No newline at end of file + + const normalThresholds: Record = { + fasting: 5.6, + premeal: 6.1, + postmeal: 7.8, + bedtime: 6.7, + } + + const result: any = {} + + Object.entries(periodMap).forEach(([chineseKey, englishKey]) => { + const periodRecords = filtered.filter(r => r.period === chineseKey) + if (periodRecords.length === 0) { + result[englishKey] = { + totalRecords: 0, + startValue: 0, + endValue: 0, + maxValue: 0, + minValue: 0, + avgValue: 0, + netChange: 0, + normalCount: 0, + abnormalCount: 0, + abnormalRate: 0, + maxConsecutiveAbnormal: 0, + } + return + } + const sorted = sortByTime(periodRecords) + const values = sorted.map(r => r.value) + const threshold = normalThresholds[englishKey] + const abnormalCount = values.filter(v => v >= threshold).length + + result[englishKey] = { + totalRecords: values.length, + startValue: values[0], + endValue: values[values.length - 1], + maxValue: max(values), + minValue: min(values), + avgValue: avg(values), + netChange: netChange(values[0], values[values.length - 1]), + normalCount: values.length - abnormalCount, + abnormalCount: abnormalCount, + abnormalRate: percent(abnormalCount, values.length), + maxConsecutiveAbnormal: maxConsecutive(values, v => v >= threshold), + } + }) + + return result as BloodSugarReport +} + +function calcTemperatureReport(records: IHealthRecord[]): TemperatureReport | null { + const filtered = records.filter(r => r.type === 'temperature') as TemperatureRecord[] + if (filtered.length === 0) return null + const sorted = sortByTime(filtered) + const values = sorted.map(r => r.value) + + const normal = values.filter(v => v >= 36.0 && v <= 37.2) + const lowFever = values.filter(v => v >= 37.3 && v <= 38.0) + const highFever = values.filter(v => v >= 38.1) + + return { + totalRecords: values.length, + startValue: values[0], + endValue: values[values.length - 1], + maxValue: max(values), + minValue: min(values), + avgValue: avg(values), + netChange: netChange(values[0], values[values.length - 1]), + normalDays: normal.length, + lowFeverDays: lowFever.length, + highFeverDays: highFever.length, + maxConsecutiveAbnormal: maxConsecutive(values, v => v >= 37.3), + } +} + +function getDaysInMonth(year: number, month: number): number { + return new Date(year, month, 0).getDate() +} + +/** + * 生成月度报告 + * @param records 记录 + * @param month 月份 YYYY-MM + * @param userInfo 用户信息 + */ +export function generateMonthlyReport(records: IHealthRecord[], month: string, userInfo: IProfile): MonthlyReport { + const recordDates = new Set() + records.forEach(r => { + recordDates.add(getDateStringFromTimestamp(r.timestamp)) + }) + const parts = month.split('-') + const totalDays = getDaysInMonth(parseInt(parts[0]), parseInt(parts[1])) + return { + userInfo: userInfo, + month, + summary: { + totalDays, + recordDays: recordDates.size, + coverageRate: percent(recordDates.size, totalDays), + }, + weight: calcWeightReport(records), + sleep: calcSleepReport(records), + sport: calcSportReport(records), + bloodPressure: calcBloodPressureReport(records), + bloodSugar: calcBloodSugarReport(records), + temperature: calcTemperatureReport(records), + } +}