feat: 优化AI报告显示模块

This commit is contained in:
2026-09-04 15:55:09 +08:00
parent 4c6167cf22
commit 89c23058f0
8 changed files with 830 additions and 160 deletions

View File

@@ -6,14 +6,105 @@
:close-on-click-modal="false"
custom-style="border-radius: 32rpx 32rpx 0 0; padding: 40rpx 32rpx"
>
<view class="flex flex-col align-center gap-2 max-h-[70vh] overflow-y-auto">
<wd-text :text="recordStore.aiResult" />
<view class="flex flex-col align-center gap-3 max-h-[80vh] overflow-y-auto">
<!-- ========== 标题 ========== -->
<view class="text-center pb-[24rpx] border-b-2 border-[#f0f2f5]">
<!-- 标题文字 -->
<wd-text
:text="recordStore.aiResult.title"
type="primary"
size="40rpx"
bold
custom-class="block"
/>
<!-- 副标题/日期 -->
<view class="mt-[8rpx]">
<wd-text text="—— 月度健康报告 · 为你定制 ——" size="24rpx" color="#999" />
</view>
</view>
<view class="p-[24rpx_28rpx] bg-gradient-to-r from-[#f0f6ff] to-[#f8faff] rounded-[16rpx] border-l-4 border-[#4A90D9] shadow-[0_2rpx_12rpx_rgba(74,144,217,0.12)]">
<wd-text
:text="recordStore.aiResult.greeting"
size="28rpx"
line-height="48rpx"
color="#2c3e50"
/>
</view>
<!-- ========== 各指标卡片 ========== -->
<view
v-for="section in recordStore.aiResult.sections"
:key="section.type"
class="p-[24rpx_28rpx] rounded-[16rpx] border-l-4 flex flex-col gap-3"
:class="[section.hasAbnormal ? 'bg-[#fdf8f7] border-[#e74c3c]' : 'bg-[#fafbfc] border-[#4A90D9]']"
>
<!-- 标题行图标 + 标题 + 状态标签 -->
<view class="flex items-center justify-between">
<view class="flex items-center gap-1">
<wd-text :text="section.emoji" size="36rpx" custom-class="mr-[12rpx]" />
<wd-text :text="section.title" size="32rpx" bold color="#1a2a3a" />
</view>
<wd-tag :type="section.hasAbnormal ? 'danger' : 'primary'" round>
{{ section.hasAbnormal ? '需关注' : '正常' }}
</wd-tag>
</view>
<!-- 数据内容 -->
<wd-text :text="section.data" size="28rpx" line-height="44rpx" color="#34495e" custom-class="block"/>
<!-- 建议 -->
<view class="p-[14rpx_18rpx] rounded-[10rpx]" :class="[section.hasAbnormal ? 'bg-[#fef0ef]' : 'bg-[#eef5ff]']">
<wd-text
:text="(section.hasAbnormal ? '⚠️ ' : '💡 ') + section.suggestion"
:type="section.hasAbnormal ? 'error' : 'primary'"
size="26rpx"
line-height="44rpx"
/>
</view>
</view>
<!-- ========== 下月健康建议 ========== -->
<view class="flex flex-col gap-3 p-[28rpx_24rpx] bg-gradient-to-r from-[#fafffe] to-[#f5fcf9] rounded-[16rpx] border-2 border-[#e8f5ee]">
<wd-text text="📋 健康建议" type="success" size="32rpx" bold />
<!-- 建议列表 -->
<view
v-for="(tip, index) in recordStore.aiResult.tips"
:key="index"
class="flex items-start py-[14rpx] border-b border-[#f0f5f2] last:border-none"
>
<!-- 序号圆点 -->
<view class="shrink-0 w-[40rpx] h-[40rpx] mr-[16rpx] mt-[4rpx] bg-[#4A90D9] rounded-full text-center leading-[40rpx]">
<wd-text :text="String(index + 1)" size="22rpx" bold color="#ffffff" />
</view>
<!-- 建议内容 -->
<wd-text
:text="tip"
size="26rpx"
line-height="44rpx"
color="#2c3e50"
custom-class="flex-1"
/>
</view>
</view>
<!-- ========== 结束语 ========== -->
<view class="p-[28rpx_32rpx] bg-gradient-to-r from-[#f0f7ff] to-[#e8f0fe] rounded-[16rpx] text-center border border-[#dce8f5]">
<wd-text
:text="recordStore.aiResult.ending"
size="28rpx"
line-height="48rpx"
color="#2c3e50"
custom-class="block"
/>
</view>
<view class="flex w-full items-center justify-evenly">
<wd-button type="info" @click="cancelClick">
取消
</wd-button>
<wd-button type="primary">
<wd-button type="primary" open-type="share">
分享
</wd-button>
</view>
@@ -23,6 +114,7 @@
<script setup lang="ts">
import { useRecordStore } from '@/store/record'
import { onShareAppMessage } from '@dcloudio/uni-app'
const recordStore = useRecordStore()
@@ -30,4 +122,13 @@ function cancelClick() {
recordStore.showAIResultPopup = false
recordStore.showAIButton = true
}
onShareAppMessage(() => {
const id = 123
return {
title: recordStore.aiResult?.title,
path: `/pages/report/index?reportId=${id}`,
imageUrl: '/static/tabs/record-active.png'
}
})
</script>

View File

@@ -15,6 +15,10 @@
{
"path": "pages/user/index",
"style": { "navigationBarTitleText": "我的" }
},
{
"path": "pages/report/index",
"style": { "navigationBarTitleText": "健康报告" }
}
],
"tabBar": {

View File

@@ -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: '提示',

165
src/pages/report/index.vue Normal file
View File

@@ -0,0 +1,165 @@
<template>
<view class="p-[24rpx_28rpx] flex flex-col align-center gap-3 overflow-y-auto pb-[140rpx]">
<!-- ========== 标题 ========== -->
<view class="text-center pb-[24rpx] border-b-2 border-[#f0f2f5]">
<!-- 标题文字 -->
<wd-text
:text="report.title"
type="primary"
size="40rpx"
bold
custom-class="block"
/>
<!-- 副标题/日期 -->
<view class="mt-[8rpx]">
<wd-text text="—— 月度健康报告 · 为你定制 ——" size="24rpx" color="#999" />
</view>
</view>
<view class="p-[24rpx_28rpx] bg-gradient-to-r from-[#f0f6ff] to-[#f8faff] rounded-[16rpx] border-l-4 border-[#4A90D9] shadow-[0_2rpx_12rpx_rgba(74,144,217,0.12)]">
<wd-text
:text="report.greeting"
size="28rpx"
line-height="48rpx"
color="#2c3e50"
/>
</view>
<!-- ========== 各指标卡片 ========== -->
<view
v-for="section in report.sections"
:key="section.type"
class="p-[24rpx_28rpx] rounded-[16rpx] border-l-4 flex flex-col gap-3"
:class="[section.hasAbnormal ? 'bg-[#fdf8f7] border-[#e74c3c]' : 'bg-[#fafbfc] border-[#4A90D9]']"
>
<!-- 标题行图标 + 标题 + 状态标签 -->
<view class="flex items-center justify-between">
<view class="flex items-center gap-1">
<wd-text :text="section.emoji" size="36rpx" custom-class="mr-[12rpx]" />
<wd-text :text="section.title" size="32rpx" bold color="#1a2a3a" />
</view>
<wd-tag :type="section.hasAbnormal ? 'danger' : 'primary'" round>
{{ section.hasAbnormal ? '需关注' : '正常' }}
</wd-tag>
</view>
<!-- 数据内容 -->
<wd-text :text="section.data" size="28rpx" line-height="44rpx" color="#34495e" custom-class="block"/>
<!-- 建议 -->
<view class="p-[14rpx_18rpx] rounded-[10rpx]" :class="[section.hasAbnormal ? 'bg-[#fef0ef]' : 'bg-[#eef5ff]']">
<wd-text
:text="(section.hasAbnormal ? '⚠️ ' : '💡 ') + section.suggestion"
:type="section.hasAbnormal ? 'error' : 'primary'"
size="26rpx"
line-height="44rpx"
/>
</view>
</view>
<!-- ========== 下月健康建议 ========== -->
<view class="flex flex-col gap-3 p-[28rpx_24rpx] bg-gradient-to-r from-[#fafffe] to-[#f5fcf9] rounded-[16rpx] border-2 border-[#e8f5ee]">
<wd-text text="📋 健康建议" type="success" size="32rpx" bold />
<!-- 建议列表 -->
<view
v-for="(tip, index) in report.tips"
:key="index"
class="flex items-start py-[14rpx] border-b border-[#f0f5f2] last:border-none"
>
<!-- 序号圆点 -->
<view class="shrink-0 w-[40rpx] h-[40rpx] mr-[16rpx] mt-[4rpx] bg-[#4A90D9] rounded-full text-center leading-[40rpx]">
<wd-text :text="String(index + 1)" size="22rpx" bold color="#ffffff" />
</view>
<!-- 建议内容 -->
<wd-text
:text="tip"
size="26rpx"
line-height="44rpx"
color="#2c3e50"
custom-class="flex-1"
/>
</view>
</view>
<!-- ========== 结束语 ========== -->
<view class="p-[28rpx_32rpx] bg-gradient-to-r from-[#f0f7ff] to-[#e8f0fe] rounded-[16rpx] text-center border border-[#dce8f5]">
<wd-text
:text="report.ending"
size="28rpx"
line-height="48rpx"
color="#2c3e50"
custom-class="block"
/>
</view>
<view class="fixed bottom-0 left-0 right-0 p-[32rpx] bg-white border-t border-[#f0f2f5]">
<wd-button type="primary" block @click="goHome">
返回主页
</wd-button>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { IAiReport } from "@/type/report";
const report = ref<IAiReport>({
title: "Cxx的健康月报 · 2026年9月",
greeting: "Cxx你好这个月你完成了3天的健康数据记录虽然覆盖还不算全面但坚持记录就是好的开始。当前体重约69.9kgBMI约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: "健康管理是一场长跑,不必追求完美,只要持续用心就好。下个月继续一起关注血压和运动,期待你更好的状态!"
})
function goHome() {
uni.switchTab({ url: '/pages/index/index' })
}
</script>

View File

@@ -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.9kgBMI约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() {

View File

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

146
src/type/report.ts Normal file
View File

@@ -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<string, number>
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
}

View File

@@ -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<T extends IHealthRecord>(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<string, number> = {}
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<string>()
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<string>()
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<string, string> = {
'空腹': 'fasting',
'餐前': 'premeal',
'餐后2h': 'postmeal',
'睡前': 'bedtime',
}
}
const normalThresholds: Record<string, number> = {
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<string>()
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),
}
}