# 体重管理功能实现文档 ## 一、概述 本文档详细说明在 `WeightComponent` (`campus_health_app/frontend/campus-health-app/src/app/modules/weight/weight.component.ts`) 中实现完整体重管理系统的技术方案。所有功能在单个组件中实现,使用 ECharts 进行数据可视化,采用 LocalStorage + 后端 API 同步的数据持久化策略。 ## 二、依赖安装 ### 2.1 安装 ECharts 相关依赖 ```bash cd campus_health_app/frontend/campus-health-app npm install echarts@5.4.3 --save npm install ngx-echarts@17.0.0 --save ``` ### 2.2 在 app.config.ts 中配置 ECharts ```typescript import { provideEcharts } from 'ngx-echarts'; export const appConfig: ApplicationConfig = { providers: [ // ... 其他 providers provideEcharts(), ] }; ``` ## 三、数据模型设计 ### 3.1 创建数据模型文件 创建 `src/app/modules/weight/models/weight.models.ts`: ```typescript /** * 体重记录数据模型 */ export interface WeightRecord { id?: string; // 记录ID(后端生成) date: string; // 记录日期 (YYYY-MM-DD) weight: number; // 体重(kg) bodyFat: number; // 体脂率(%) muscleMass: number; // 肌肉含量(kg) measurementTime?: string; // 测量时间 (HH:mm) measurementCondition?: 'fasting' | 'after_meal'; // 测量条件:空腹/餐后 notes?: string; // 备注 tags?: string[]; // 关键节点标记(如:开始运动日、目标调整日) createdAt?: number; // 创建时间戳 updatedAt?: number; // 更新时间戳 syncStatus?: 'synced' | 'pending'; // 同步状态 } /** * 体重目标数据模型 */ export interface WeightGoal { id?: string; // 目标ID targetWeight: number; // 目标体重(kg) targetBodyFat?: number; // 目标体脂率(%) targetDate: string; // 目标日期 (YYYY-MM-DD) startWeight: number; // 起始体重(kg) startBodyFat?: number; // 起始体脂率(%) startDate: string; // 开始日期 (YYYY-MM-DD) weeklyTarget?: number; // 每周目标减重量(kg) createdAt?: number; updatedAt?: number; syncStatus?: 'synced' | 'pending'; } /** * 异常提醒数据模型 */ export interface AnomalyAlert { id: string; type: 'rapid_change' | 'body_fat_anomaly' | 'missing_data' | 'extreme_value'; severity: 'info' | 'warning' | 'danger'; message: string; detectedDate: string; relatedRecordIds?: string[]; } /** * LocalStorage 数据结构 */ export interface WeightLocalData { records: WeightRecord[]; goal: WeightGoal | null; pendingSync: { records: WeightRecord[]; goal: WeightGoal | null; }; lastSyncTime: number; } /** * 筛选条件 */ export interface WeightFilter { timePeriod: '7days' | '30days' | '90days' | 'all' | 'custom'; startDate?: string; endDate?: string; condition?: 'all' | 'fasting' | 'after_meal'; tags?: string[]; } /** * 统计数据 */ export interface WeightStats { currentWeight: number; weightChange: number; daysTracked: number; avgWeeklyChange: number; bodyFatChange: number; goalETA: string | null; } ``` ## 四、服务层实现 ### 4.1 创建 WeightDataService 创建 `src/app/services/weight-data.service.ts`: ```typescript import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { BehaviorSubject, Observable, interval } from 'rxjs'; import { map, catchError } from 'rxjs/operators'; import { WeightRecord, WeightGoal, WeightLocalData, AnomalyAlert } from '../modules/weight/models/weight.models'; @Injectable({ providedIn: 'root' }) export class WeightDataService { private readonly STORAGE_KEY = 'weight_data'; private readonly API_BASE = '/api/weight'; private readonly SYNC_INTERVAL = 5 * 60 * 1000; // 5分钟 private recordsSubject = new BehaviorSubject([]); private goalSubject = new BehaviorSubject(null); public records$ = this.recordsSubject.asObservable(); public goal$ = this.goalSubject.asObservable(); constructor(private http: HttpClient) { this.initializeData(); this.startAutoSync(); } /** * 初始化数据:从 localStorage 加载并从后端同步 */ private initializeData(): void { const localData = this.getLocalData(); this.recordsSubject.next(localData.records); this.goalSubject.next(localData.goal); // 从后端同步最新数据 this.syncFromBackend(); } /** * 从 localStorage 获取数据 */ private getLocalData(): WeightLocalData { const data = localStorage.getItem(this.STORAGE_KEY); if (data) { return JSON.parse(data); } return { records: [], goal: null, pendingSync: { records: [], goal: null }, lastSyncTime: 0 }; } /** * 保存数据到 localStorage */ private saveLocalData(data: WeightLocalData): void { localStorage.setItem(this.STORAGE_KEY, JSON.stringify(data)); } /** * 添加体重记录 */ addRecord(record: WeightRecord): void { const localData = this.getLocalData(); // 生成临时ID和时间戳 record.id = record.id || `temp_${Date.now()}`; record.createdAt = Date.now(); record.syncStatus = 'pending'; // 添加到记录列表 localData.records.unshift(record); // 添加到待同步队列 localData.pendingSync.records.push(record); // 保存并更新 this.saveLocalData(localData); this.recordsSubject.next(localData.records); } /** * 更新体重记录 */ updateRecord(id: string, updates: Partial): void { const localData = this.getLocalData(); const index = localData.records.findIndex(r => r.id === id); if (index !== -1) { localData.records[index] = { ...localData.records[index], ...updates, updatedAt: Date.now(), syncStatus: 'pending' }; // 添加到待同步队列 localData.pendingSync.records.push(localData.records[index]); this.saveLocalData(localData); this.recordsSubject.next(localData.records); } } /** * 删除体重记录 */ deleteRecord(id: string): void { const localData = this.getLocalData(); localData.records = localData.records.filter(r => r.id !== id); this.saveLocalData(localData); this.recordsSubject.next(localData.records); // 如果记录已同步到后端,需要调用删除API if (!id.startsWith('temp_')) { this.http.delete(`${this.API_BASE}/records/${id}`).subscribe(); } } /** * 设置目标 */ setGoal(goal: WeightGoal): void { const localData = this.getLocalData(); goal.createdAt = Date.now(); goal.syncStatus = 'pending'; // 计算每周目标 const daysDiff = this.calculateDaysDiff(goal.startDate, goal.targetDate); const weightDiff = goal.startWeight - goal.targetWeight; goal.weeklyTarget = (weightDiff / daysDiff) * 7; localData.goal = goal; localData.pendingSync.goal = goal; this.saveLocalData(localData); this.goalSubject.next(goal); } /** * 获取目标 */ getGoal(): WeightGoal | null { return this.goalSubject.value; } /** * 获取筛选后的记录 */ getFilteredRecords(filter: any): WeightRecord[] { let records = this.recordsSubject.value; // 时间筛选 if (filter.timePeriod !== 'all') { const now = new Date(); let startDate: Date; switch (filter.timePeriod) { case '7days': startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); break; case '30days': startDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); break; case '90days': startDate = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000); break; case 'custom': startDate = filter.startDate ? new Date(filter.startDate) : new Date(0); break; default: startDate = new Date(0); } records = records.filter(r => new Date(r.date) >= startDate); if (filter.timePeriod === 'custom' && filter.endDate) { const endDate = new Date(filter.endDate); records = records.filter(r => new Date(r.date) <= endDate); } } // 测量条件筛选 if (filter.condition && filter.condition !== 'all') { records = records.filter(r => r.measurementCondition === filter.condition); } // 标签筛选 if (filter.tags && filter.tags.length > 0) { records = records.filter(r => r.tags && r.tags.some(tag => filter.tags.includes(tag)) ); } return records; } /** * 从后端同步数据 */ private syncFromBackend(): void { // 获取记录 this.http.get(`${this.API_BASE}/records`) .pipe(catchError(() => [])) .subscribe(backendRecords => { const localData = this.getLocalData(); // 合并本地和后端数据(后端数据优先) const mergedRecords = this.mergeRecords(localData.records, backendRecords); localData.records = mergedRecords; localData.lastSyncTime = Date.now(); this.saveLocalData(localData); this.recordsSubject.next(mergedRecords); }); // 获取目标 this.http.get(`${this.API_BASE}/goal`) .pipe(catchError(() => null)) .subscribe(backendGoal => { if (backendGoal) { const localData = this.getLocalData(); localData.goal = backendGoal; this.saveLocalData(localData); this.goalSubject.next(backendGoal); } }); } /** * 同步到后端 */ private syncToBackend(): void { const localData = this.getLocalData(); // 同步待同步的记录 if (localData.pendingSync.records.length > 0) { localData.pendingSync.records.forEach(record => { if (record.id?.startsWith('temp_')) { // 新记录 - POST this.http.post(`${this.API_BASE}/records`, record) .subscribe(savedRecord => { this.updateRecordId(record.id!, savedRecord.id!); }); } else { // 更新记录 - PUT this.http.put(`${this.API_BASE}/records/${record.id}`, record) .subscribe(); } }); // 清空待同步队列 localData.pendingSync.records = []; } // 同步目标 if (localData.pendingSync.goal) { const goal = localData.pendingSync.goal; if (goal.id) { this.http.put(`${this.API_BASE}/goal`, goal).subscribe(); } else { this.http.post(`${this.API_BASE}/goal`, goal) .subscribe(savedGoal => { localData.goal = savedGoal; this.saveLocalData(localData); this.goalSubject.next(savedGoal); }); } localData.pendingSync.goal = null; } localData.lastSyncTime = Date.now(); this.saveLocalData(localData); } /** * 启动自动同步 */ private startAutoSync(): void { interval(this.SYNC_INTERVAL).subscribe(() => { this.syncToBackend(); }); } /** * 合并本地和后端记录 */ private mergeRecords(local: WeightRecord[], backend: WeightRecord[]): WeightRecord[] { const merged = [...backend]; // 添加本地未同步的记录 local.forEach(localRecord => { if (localRecord.id?.startsWith('temp_')) { merged.push(localRecord); } }); // 按日期排序(最新在前) return merged.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime() ); } /** * 更新记录ID(临时ID -> 后端ID) */ private updateRecordId(tempId: string, newId: string): void { const localData = this.getLocalData(); const record = localData.records.find(r => r.id === tempId); if (record) { record.id = newId; record.syncStatus = 'synced'; this.saveLocalData(localData); this.recordsSubject.next(localData.records); } } /** * 计算日期差 */ private calculateDaysDiff(startDate: string, endDate: string): number { const start = new Date(startDate); const end = new Date(endDate); return Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)); } } ``` ## 五、组件实现 ### 5.1 WeightComponent 核心逻辑 更新 `weight.component.ts`: ```typescript import { Component, OnInit, OnDestroy } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { Router, RouterModule } from '@angular/router'; import { NgxEchartsModule, provideEcharts } from 'ngx-echarts'; import { EChartsOption } from 'echarts'; import { Subject, takeUntil } from 'rxjs'; import { WeightDataService } from '../../services/weight-data.service'; import { WeightRecord, WeightGoal, WeightFilter, WeightStats, AnomalyAlert } from './models/weight.models'; @Component({ selector: 'app-weight', standalone: true, imports: [CommonModule, FormsModule, RouterModule, NgxEchartsModule], templateUrl: './weight.component.html', styleUrl: './weight.component.scss' }) export class WeightComponent implements OnInit, OnDestroy { private destroy$ = new Subject(); // 数据 records: WeightRecord[] = []; goal: WeightGoal | null = null; filteredRecords: WeightRecord[] = []; anomalies: AnomalyAlert[] = []; stats: WeightStats | null = null; // 筛选条件 filter: WeightFilter = { timePeriod: '30days', condition: 'all', tags: [] }; // UI 状态 showAddRecordDialog = false; showGoalDialog = false; chartPeriod: 'weekly' | 'monthly' = 'weekly'; // 新记录表单 newRecord: Partial = { date: new Date().toISOString().split('T')[0], measurementTime: new Date().toTimeString().slice(0, 5), measurementCondition: 'fasting', tags: [] }; // 新目标表单 newGoal: Partial = {}; // ECharts 配置 trendChartOption: EChartsOption = {}; changeChartOption: EChartsOption = {}; scatterChartOption: EChartsOption = {}; progressChartOption: EChartsOption = {}; constructor( private router: Router, private weightDataService: WeightDataService ) {} ngOnInit(): void { // 订阅数据变化 this.weightDataService.records$ .pipe(takeUntil(this.destroy$)) .subscribe(records => { this.records = records; this.applyFilter(); this.updateCharts(); this.detectAnomalies(); this.calculateStats(); }); this.weightDataService.goal$ .pipe(takeUntil(this.destroy$)) .subscribe(goal => { this.goal = goal; this.updateCharts(); this.calculateStats(); }); } ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); } /** * 应用筛选条件 */ applyFilter(): void { this.filteredRecords = this.weightDataService.getFilteredRecords(this.filter); } /** * 更新时间筛选 */ updateTimePeriod(period: '7days' | '30days' | '90days' | 'all'): void { this.filter.timePeriod = period; this.applyFilter(); this.updateCharts(); } /** * 添加记录 */ addRecord(): void { if (this.validateRecord(this.newRecord)) { this.weightDataService.addRecord(this.newRecord as WeightRecord); this.resetRecordForm(); this.showAddRecordDialog = false; } } /** * 设置目标 */ setGoal(): void { if (this.validateGoal(this.newGoal)) { // 设置起始数据 if (this.records.length > 0) { const latestRecord = this.records[0]; this.newGoal.startWeight = latestRecord.weight; this.newGoal.startBodyFat = latestRecord.bodyFat; this.newGoal.startDate = latestRecord.date; } this.weightDataService.setGoal(this.newGoal as WeightGoal); this.resetGoalForm(); this.showGoalDialog = false; } } /** * 更新所有图表 */ private updateCharts(): void { this.updateTrendChart(); this.updateChangeChart(); this.updateScatterChart(); this.updateProgressChart(); } /** * 更新趋势折线图 */ private updateTrendChart(): void { const records = [...this.filteredRecords].reverse(); // 从旧到新排序 // 准备数据 const dates = records.map(r => r.date); const weights = records.map(r => r.weight); // 计划趋势线数据 const plannedWeights = this.calculatePlannedTrend(records); // 目标线数据 const targetWeights = this.goal ? new Array(dates.length).fill(this.goal.targetWeight) : []; // 标注点(关键节点) const markPoints = this.extractMarkPoints(records); this.trendChartOption = { title: { text: '体重趋势', left: 'center', textStyle: { fontSize: 16, fontWeight: 'bold' } }, tooltip: { trigger: 'axis', formatter: (params: any) => { const index = params[0].dataIndex; const record = records[index]; return `
${record.date}
体重: ${record.weight} kg
体脂率: ${record.bodyFat}%
测量条件: ${record.measurementCondition === 'fasting' ? '空腹' : '餐后'}
${record.notes ? `
备注: ${record.notes}
` : ''}
`; } }, legend: { data: ['当前体重', '目标体重', '计划趋势'], bottom: 10 }, grid: { left: '3%', right: '4%', bottom: '15%', containLabel: true }, xAxis: { type: 'category', data: dates, boundaryGap: false, axisLabel: { formatter: (value: string) => { const date = new Date(value); return `${date.getMonth() + 1}/${date.getDate()}`; } } }, yAxis: { type: 'value', name: '体重 (kg)', axisLabel: { formatter: '{value} kg' } }, dataZoom: [ { type: 'inside', start: 0, end: 100 }, { start: 0, end: 100, height: 20, bottom: 40 } ], series: [ { name: '当前体重', type: 'line', data: weights, smooth: true, itemStyle: { color: '#3b82f6' }, lineStyle: { width: 3 }, markPoint: { data: markPoints, symbolSize: 50, label: { formatter: '{b}', fontSize: 10 } } }, ...(this.goal ? [{ name: '目标体重', type: 'line', data: targetWeights, itemStyle: { color: '#ef4444' }, lineStyle: { width: 2, type: 'dashed' }, symbol: 'none' }] : []), ...(plannedWeights.length > 0 ? [{ name: '计划趋势', type: 'line', data: plannedWeights, itemStyle: { color: '#9ca3af' }, lineStyle: { width: 2, type: 'dotted' }, symbol: 'none' }] : []) ] }; } /** * 更新周/月体重变化柱状图 */ private updateChangeChart(): void { const changes = this.calculateWeightChanges(); this.changeChartOption = { title: { text: this.chartPeriod === 'weekly' ? '周体重变化' : '月体重变化', left: 'center', textStyle: { fontSize: 16, fontWeight: 'bold' } }, tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, formatter: (params: any) => { const value = params[0].value; const sign = value >= 0 ? '+' : ''; return `${params[0].name}
变化: ${sign}${value.toFixed(2)} kg`; } }, grid: { left: '3%', right: '4%', bottom: '10%', containLabel: true }, xAxis: { type: 'category', data: changes.labels, axisLabel: { interval: 0, rotate: this.chartPeriod === 'weekly' ? 0 : 30 } }, yAxis: { type: 'value', name: '变化量 (kg)', axisLabel: { formatter: '{value} kg' } }, series: [ { type: 'bar', data: changes.values.map(v => ({ value: v, itemStyle: { color: v < 0 ? '#10b981' : '#ef4444' // 减重绿色,增重红色 } })), label: { show: true, position: 'top', formatter: (params: any) => { const value = params.value; const sign = value >= 0 ? '+' : ''; return `${sign}${value.toFixed(1)}kg`; }, color: '#000' }, barWidth: '60%' } ] }; } /** * 更新体重-体脂率散点图 */ private updateScatterChart(): void { const data = this.filteredRecords.map(r => [r.weight, r.bodyFat, r.date]); this.scatterChartOption = { title: { text: '体重 vs 体脂率', left: 'center', textStyle: { fontSize: 14, fontWeight: 'bold' } }, tooltip: { formatter: (params: any) => { const [weight, bodyFat, date] = params.value; return `
${date}
体重: ${weight} kg
体脂率: ${bodyFat}%
`; } }, grid: { left: '15%', right: '5%', bottom: '15%', top: '20%' }, xAxis: { type: 'value', name: '体重 (kg)', nameLocation: 'middle', nameGap: 30 }, yAxis: { type: 'value', name: '体脂率 (%)', nameLocation: 'middle', nameGap: 40 }, visualMap: { min: 0, max: data.length - 1, dimension: 2, orient: 'vertical', right: 10, top: 'center', text: ['新', '旧'], calculable: true, inRange: { color: ['#e0e0e0', '#3b82f6'] } }, series: [ { type: 'scatter', data: data.map((d, index) => [...d, index]), symbolSize: 12, emphasis: { itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0, 0, 0, 0.5)' } } } ] }; } /** * 更新目标进度环形图 */ private updateProgressChart(): void { if (!this.goal || this.records.length === 0) { this.progressChartOption = {}; return; } const currentWeight = this.records[0].weight; const totalToLose = this.goal.startWeight - this.goal.targetWeight; const achieved = this.goal.startWeight - currentWeight; const progress = Math.min((achieved / totalToLose) * 100, 100); const remaining = Math.max(this.goal.targetWeight - currentWeight, 0); // 计算预计完成时间 const eta = this.calculateETA(); this.progressChartOption = { title: { text: '目标进度', left: 'center', top: 10, textStyle: { fontSize: 14, fontWeight: 'bold' } }, series: [ { type: 'pie', radius: ['60%', '80%'], center: ['50%', '55%'], avoidLabelOverlap: false, label: { show: true, position: 'center', formatter: () => { return `{a|${progress.toFixed(0)}%}\n{b|已减 ${achieved.toFixed(1)}kg}\n{c|还需 ${remaining.toFixed(1)}kg}\n{d|${eta}}`; }, rich: { a: { fontSize: 24, fontWeight: 'bold', color: '#3b82f6' }, b: { fontSize: 12, color: '#666', padding: [10, 0, 0, 0] }, c: { fontSize: 12, color: '#666', padding: [5, 0, 0, 0] }, d: { fontSize: 10, color: '#999', padding: [5, 0, 0, 0] } } }, labelLine: { show: false }, data: [ { value: progress, itemStyle: { color: progress >= 100 ? '#10b981' : progress >= 50 ? '#3b82f6' : '#fbbf24' } }, { value: 100 - progress, itemStyle: { color: '#e5e7eb' } } ] } ] }; } /** * 计算计划趋势线 */ private calculatePlannedTrend(records: WeightRecord[]): number[] { if (!this.goal || records.length === 0) return []; const startDate = new Date(this.goal.startDate); const endDate = new Date(this.goal.targetDate); const totalDays = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)); const totalWeightToLose = this.goal.startWeight - this.goal.targetWeight; return records.map(record => { const currentDate = new Date(record.date); const daysElapsed = Math.ceil((currentDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)); if (daysElapsed < 0) return this.goal!.startWeight; if (daysElapsed > totalDays) return this.goal!.targetWeight; return this.goal!.startWeight - (daysElapsed / totalDays) * totalWeightToLose; }); } /** * 提取关键节点标注 */ private extractMarkPoints(records: WeightRecord[]): any[] { const markPoints: any[] = []; records.forEach((record, index) => { if (record.tags && record.tags.length > 0) { record.tags.forEach(tag => { markPoints.push({ name: tag, coord: [record.date, record.weight], value: tag, itemStyle: { color: '#f59e0b' } }); }); } }); return markPoints; } /** * 计算周/月体重变化 */ private calculateWeightChanges(): { labels: string[], values: number[] } { const records = [...this.filteredRecords].reverse(); const labels: string[] = []; const values: number[] = []; if (this.chartPeriod === 'weekly') { // 按周分组 const weeks: { [key: string]: WeightRecord[] } = {}; records.forEach(record => { const date = new Date(record.date); const weekNum = this.getWeekNumber(date); const weekKey = `第${weekNum}周`; if (!weeks[weekKey]) weeks[weekKey] = []; weeks[weekKey].push(record); }); // 计算每周变化 const weekKeys = Object.keys(weeks).slice(-8); // 最近8周 weekKeys.forEach((weekKey, index) => { if (index > 0) { const prevWeek = weekKeys[index - 1]; const prevAvg = this.average(weeks[prevWeek].map(r => r.weight)); const currAvg = this.average(weeks[weekKey].map(r => r.weight)); labels.push(weekKey); values.push(currAvg - prevAvg); } }); } else { // 按月分组 const months: { [key: string]: WeightRecord[] } = {}; records.forEach(record => { const date = new Date(record.date); const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; if (!months[monthKey]) months[monthKey] = []; months[monthKey].push(record); }); // 计算每月变化 const monthKeys = Object.keys(months).sort().slice(-6); // 最近6个月 monthKeys.forEach((monthKey, index) => { if (index > 0) { const prevMonth = monthKeys[index - 1]; const prevAvg = this.average(months[prevMonth].map(r => r.weight)); const currAvg = this.average(months[monthKey].map(r => r.weight)); const [year, month] = monthKey.split('-'); labels.push(`${month}月`); values.push(currAvg - prevAvg); } }); } return { labels, values }; } /** * 计算预计完成时间 */ private calculateETA(): string { if (!this.goal || this.records.length < 4) { return '数据不足'; } const currentWeight = this.records[0].weight; const remaining = currentWeight - this.goal.targetWeight; if (remaining <= 0) { return '已完成!'; } // 计算最近4周的平均变化 const last4Weeks = this.records.slice(0, Math.min(this.records.length, 28)); const weeklyChanges: number[] = []; for (let i = 0; i < last4Weeks.length - 7; i += 7) { const weekStart = last4Weeks[i].weight; const weekEnd = last4Weeks[i + 7]?.weight; if (weekEnd) { weeklyChanges.push(weekStart - weekEnd); } } const avgWeeklyChange = this.average(weeklyChanges); if (avgWeeklyChange <= 0) { return '目标偏离'; } const weeksRemaining = remaining / avgWeeklyChange; const eta = new Date(); eta.setDate(eta.getDate() + weeksRemaining * 7); return `预计 ${eta.getMonth() + 1}月${eta.getDate()}日`; } /** * 异常检测 */ private detectAnomalies(): void { this.anomalies = []; if (this.records.length < 2) return; // 检测快速体重变化 for (let i = 0; i < this.records.length - 1; i++) { const curr = this.records[i]; const prev = this.records[i + 1]; const daysDiff = this.calculateDaysDiff(prev.date, curr.date); const weightDiff = Math.abs(curr.weight - prev.weight); // 日变化 >0.5kg if (daysDiff === 1 && weightDiff > 0.5) { this.anomalies.push({ id: `rapid_${i}`, type: 'rapid_change', severity: 'warning', message: `${curr.date} 体重变化过快(${weightDiff.toFixed(1)}kg/日)`, detectedDate: curr.date, relatedRecordIds: [curr.id!, prev.id!] }); } // 周变化 >2kg if (daysDiff >= 6 && daysDiff <= 8 && weightDiff > 2) { this.anomalies.push({ id: `rapid_week_${i}`, type: 'rapid_change', severity: 'danger', message: `${prev.date} 至 ${curr.date} 体重变化过快(${weightDiff.toFixed(1)}kg/周)`, detectedDate: curr.date, relatedRecordIds: [curr.id!, prev.id!] }); } // 体脂率异常:体重下降但体脂率上升 if (curr.weight < prev.weight && curr.bodyFat > prev.bodyFat) { this.anomalies.push({ id: `bodyfat_${i}`, type: 'body_fat_anomaly', severity: 'warning', message: `${curr.date} 体重下降但体脂率上升,可能脱水或肌肉流失`, detectedDate: curr.date, relatedRecordIds: [curr.id!] }); } } // 检测缺失数据(>7天未测量) const now = new Date(); const lastRecord = new Date(this.records[0].date); const daysSinceLastRecord = Math.ceil((now.getTime() - lastRecord.getTime()) / (1000 * 60 * 60 * 24)); if (daysSinceLastRecord > 7) { this.anomalies.push({ id: 'missing_data', type: 'missing_data', severity: 'info', message: `已有 ${daysSinceLastRecord} 天未记录体重数据`, detectedDate: now.toISOString().split('T')[0] }); } } /** * 计算统计数据 */ private calculateStats(): void { if (this.records.length === 0) { this.stats = null; return; } const current = this.records[0]; const oldest = this.records[this.records.length - 1]; const weightChange = oldest.weight - current.weight; const bodyFatChange = oldest.bodyFat - current.bodyFat; const daysTracked = this.calculateDaysDiff(oldest.date, current.date); // 计算平均周变化(最近4周) const last4Weeks = this.records.slice(0, Math.min(this.records.length, 28)); let avgWeeklyChange = 0; if (last4Weeks.length >= 7) { const weeklyChanges: number[] = []; for (let i = 0; i < last4Weeks.length - 7; i += 7) { const weekStart = last4Weeks[i + 7]?.weight; const weekEnd = last4Weeks[i].weight; if (weekStart && weekEnd) { weeklyChanges.push(weekStart - weekEnd); } } avgWeeklyChange = this.average(weeklyChanges); } this.stats = { currentWeight: current.weight, weightChange, daysTracked, avgWeeklyChange, bodyFatChange, goalETA: this.calculateETA() }; } /** * 验证记录 */ private validateRecord(record: Partial): boolean { if (!record.weight || record.weight < 30 || record.weight > 200) { alert('请输入有效的体重(30-200kg)'); return false; } if (!record.bodyFat || record.bodyFat < 5 || record.bodyFat > 60) { alert('请输入有效的体脂率(5-60%)'); return false; } if (!record.muscleMass || record.muscleMass < 10 || record.muscleMass > 100) { alert('请输入有效的肌肉量(10-100kg)'); return false; } return true; } /** * 验证目标 */ private validateGoal(goal: Partial): boolean { if (!goal.targetWeight) { alert('请输入目标体重'); return false; } if (!goal.targetDate) { alert('请选择目标日期'); return false; } const targetDate = new Date(goal.targetDate); const today = new Date(); if (targetDate <= today) { alert('目标日期必须在今天之后'); return false; } return true; } /** * 重置记录表单 */ private resetRecordForm(): void { this.newRecord = { date: new Date().toISOString().split('T')[0], measurementTime: new Date().toTimeString().slice(0, 5), measurementCondition: 'fasting', tags: [] }; } /** * 重置目标表单 */ private resetGoalForm(): void { this.newGoal = {}; } /** * 工具方法:计算日期差 */ private calculateDaysDiff(startDate: string, endDate: string): number { const start = new Date(startDate); const end = new Date(endDate); return Math.ceil(Math.abs((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24))); } /** * 工具方法:获取周数 */ private getWeekNumber(date: Date): number { const firstDayOfYear = new Date(date.getFullYear(), 0, 1); const pastDaysOfYear = (date.getTime() - firstDayOfYear.getTime()) / 86400000; return Math.ceil((pastDaysOfYear + firstDayOfYear.getDay() + 1) / 7); } /** * 工具方法:计算平均值 */ private average(numbers: number[]): number { if (numbers.length === 0) return 0; return numbers.reduce((sum, num) => sum + num, 0) / numbers.length; } /** * 返回仪表盘 */ backToDashboard(): void { this.router.navigate(['/dashboard']); } /** * 打开添加记录对话框 */ openAddRecordDialog(): void { this.showAddRecordDialog = true; } /** * 打开目标设置对话框 */ openGoalDialog(): void { if (this.goal) { this.newGoal = { ...this.goal }; } this.showGoalDialog = true; } /** * 切换图表周期 */ toggleChartPeriod(): void { this.chartPeriod = this.chartPeriod === 'weekly' ? 'monthly' : 'weekly'; this.updateChangeChart(); } } ``` ## 六、模板实现 创建 `weight.component.html`: ```html

体重管理

当前体重
{{ stats.currentWeight.toFixed(1) }} kg
体重变化
{{ stats.weightChange > 0 ? '+' : '' }}{{ stats.weightChange.toFixed(1) }} kg
平均周变化
{{ stats.avgWeeklyChange > 0 ? '+' : '' }}{{ stats.avgWeeklyChange.toFixed(2) }} kg/周

当前 {{ records[0].weight.toFixed(1) }}kg, 较 {{ records[records.length - 1].date }} {{ records[records.length - 1].weight > records[0].weight ? '减少' : '增加' }} {{ Math.abs(records[records.length - 1].weight - records[0].weight).toFixed(1) }}kg

健康提醒

{{ anomaly.severity === 'info' ? 'ℹ️' : anomaly.severity === 'warning' ? '⚠️' : '❗' }} {{ anomaly.message }}

录入体重数据

设置目标

``` ## 七、样式实现 创建 `weight.component.scss`: ```scss .weight-container { min-height: 100vh; background: #f5f5f5; padding-bottom: 20px; } // 头部 .header { background: #fff; padding: 16px; display: flex; align-items: center; justify-content: space-between; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); position: sticky; top: 0; z-index: 100; h1 { font-size: 20px; font-weight: bold; margin: 0; } .back-btn { background: none; border: none; font-size: 16px; cursor: pointer; color: #3b82f6; } .header-actions { display: flex; gap: 8px; } } // 按钮 .btn { padding: 8px 16px; border: none; border-radius: 6px; font-size: 14px; cursor: pointer; transition: all 0.3s; &.btn-primary { background: #3b82f6; color: #fff; &:hover { background: #2563eb; } } &.btn-secondary { background: #e5e7eb; color: #374151; &:hover { background: #d1d5db; } } } // 统计卡片 .stats-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; padding: 16px; } .stat-card { background: #fff; padding: 16px; border-radius: 8px; text-align: center; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); .stat-label { font-size: 12px; color: #6b7280; margin-bottom: 8px; } .stat-value { font-size: 24px; font-weight: bold; color: #111827; span { font-size: 14px; font-weight: normal; color: #6b7280; } &.positive { color: #ef4444; } &.negative { color: #10b981; } } } // 图表区域 .chart-section { display: grid; grid-template-columns: 2fr 1fr; gap: 16px; padding: 0 16px; margin-top: 16px; } .trend-chart-container { background: #fff; border-radius: 8px; padding: 16px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); .chart { width: 100%; height: 400px; } .trend-summary { margin-top: 12px; padding-top: 12px; border-top: 1px solid #e5e7eb; text-align: center; color: #6b7280; font-size: 14px; } } .progress-chart-container { background: #fff; border-radius: 8px; padding: 16px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); .chart-small { width: 100%; height: 300px; } } // 筛选栏 .filter-bar { display: flex; gap: 8px; padding: 16px; background: #fff; margin: 16px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); .filter-btn { flex: 1; padding: 10px; border: 1px solid #e5e7eb; background: #fff; border-radius: 6px; cursor: pointer; transition: all 0.3s; &.active { background: #3b82f6; color: #fff; border-color: #3b82f6; } &:hover:not(.active) { background: #f3f4f6; } } } // 变化图表 .change-chart-section { background: #fff; border-radius: 8px; padding: 16px; margin: 0 16px 16px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); .chart-header { display: flex; justify-content: flex-end; margin-bottom: 12px; .toggle-btn { background: #e5e7eb; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 12px; &:hover { background: #d1d5db; } } } .chart-medium { width: 100%; height: 300px; } } // 散点图 .scatter-chart-section { background: #fff; border-radius: 8px; padding: 16px; margin: 0 16px 16px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); .chart-medium { width: 100%; height: 300px; } } // 异常提醒 .anomaly-section { background: #fff; border-radius: 8px; padding: 16px; margin: 0 16px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); h3 { font-size: 16px; font-weight: bold; margin: 0 0 12px 0; } .anomaly-list { display: flex; flex-direction: column; gap: 8px; } .anomaly-item { display: flex; align-items: center; gap: 8px; padding: 12px; border-radius: 6px; font-size: 14px; &.info { background: #dbeafe; color: #1e40af; } &.warning { background: #fef3c7; color: #92400e; } &.danger { background: #fee2e2; color: #991b1b; } .anomaly-icon { font-size: 18px; } } } // 对话框 .dialog-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 20px; } .dialog { background: #fff; border-radius: 12px; padding: 24px; max-width: 500px; width: 100%; max-height: 90vh; overflow-y: auto; h2 { font-size: 18px; font-weight: bold; margin: 0 0 20px 0; } .form-group { margin-bottom: 16px; label { display: block; font-size: 14px; font-weight: 500; margin-bottom: 6px; color: #374151; } input, select, textarea { width: 100%; padding: 10px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 14px; &:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); } } textarea { resize: vertical; font-family: inherit; } } .dialog-actions { display: flex; gap: 12px; margin-top: 24px; button { flex: 1; } } } // 移动端适配 @media (max-width: 768px) { .stats-cards { grid-template-columns: 1fr; } .chart-section { grid-template-columns: 1fr; } .progress-chart-container { order: -1; } .header { flex-wrap: wrap; h1 { flex: 1 0 100%; text-align: center; margin: 8px 0; } .header-actions { flex: 1; justify-content: center; } } .filter-bar { flex-wrap: wrap; .filter-btn { flex: 1 1 calc(50% - 4px); } } } ``` ## 八、模拟数据生成 为了测试功能,在组件中添加模拟数据生成方法: ```typescript // 在 WeightComponent 中添加 generateMockData(): void { const mockRecords: WeightRecord[] = []; const startDate = new Date(); startDate.setDate(startDate.getDate() - 90); let weight = 70; for (let i = 0; i < 90; i++) { const date = new Date(startDate); date.setDate(date.getDate() + i); // 模拟体重波动 weight += (Math.random() - 0.55) * 0.3; // 每周三添加标签 const tags: string[] = []; if (date.getDay() === 3 && i % 14 === 0) { tags.push(i === 14 ? '开始运动' : '目标调整'); } mockRecords.push({ id: `mock_${i}`, date: date.toISOString().split('T')[0], weight: Math.round(weight * 10) / 10, bodyFat: Math.round((20 + Math.random() * 3) * 10) / 10, muscleMass: Math.round((28 + Math.random() * 2) * 10) / 10, measurementTime: '08:00', measurementCondition: i % 2 === 0 ? 'fasting' : 'after_meal', tags, createdAt: date.getTime(), syncStatus: 'synced' }); } // 保存模拟数据 const localData = { records: mockRecords.reverse(), goal: { targetWeight: 65, targetBodyFat: 18, targetDate: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], startWeight: 70, startBodyFat: 22, startDate: mockRecords[mockRecords.length - 1].date, weeklyTarget: 0.5 } as WeightGoal, pendingSync: { records: [], goal: null }, lastSyncTime: Date.now() }; localStorage.setItem('weight_data', JSON.stringify(localData)); window.location.reload(); } ``` ## 九、API 接口规范 后端需要提供以下接口: ### 9.1 体重记录接口 **GET /api/weight/records** - 查询参数:`from` (起始日期), `to` (结束日期) - 响应:`WeightRecord[]` **POST /api/weight/records** - 请求体:`WeightRecord` - 响应:`WeightRecord` (包含生成的 ID) **PUT /api/weight/records/:id** - 请求体:`WeightRecord` - 响应:`WeightRecord` **DELETE /api/weight/records/:id** - 响应:`{ success: true }` ### 9.2 目标接口 **GET /api/weight/goal** - 响应:`WeightGoal` **POST /api/weight/goal** - 请求体:`WeightGoal` - 响应:`WeightGoal` **PUT /api/weight/goal** - 请求体:`WeightGoal` - 响应:`WeightGoal` ## 十、测试清单 ### 10.1 功能测试 - [ ] 添加体重记录并验证数据保存 - [ ] 设置目标并验证计算正确性 - [ ] 测试各种时间筛选(7天/30天/90天/全部) - [ ] 验证异常检测逻辑(快速变化/体脂异常/缺失数据) - [ ] 测试离线模式和同步恢复 ### 10.2 可视化测试 - [ ] 趋势图正确显示三条线(当前/目标/计划) - [ ] 柱状图正确区分增重/减重(红/绿) - [ ] 散点图颜色渐变正确 - [ ] 进度环形图百分比和文本正确 - [ ] 图表缩放和交互正常 ### 10.3 边界测试 - [ ] 无数据时的界面展示 - [ ] 单条数据时的图表渲染 - [ ] 大量数据(365+条)性能测试 - [ ] 目标已达成的显示 - [ ] 目标偏离的提示 ### 10.4 移动端测试 - [ ] 响应式布局正确 - [ ] 触摸交互流畅 - [ ] 图表在小屏幕上可读 - [ ] 对话框在移动端正常显示 ## 十一、性能优化建议 1. **图表懒加载**:使用 Intersection Observer 仅在可见时渲染图表 2. **数据缓存**:对筛选结果进行缓存,避免重复计算 3. **虚拟滚动**:记录列表超过 100 条时使用 CDK Virtual Scroll 4. **防抖处理**:筛选操作添加 debounce 5. **图表更新优化**:仅在数据变化时更新图表,避免频繁重绘 ## 十二、未来扩展方向 1. **社交功能**:与好友对比进度,互相激励 2. **AI 建议**:基于历史数据提供个性化减重建议 3. **导出功能**:导出 PDF/Excel 报表 4. **提醒功能**:定时提醒测量体重 5. **运动集成**:与运动模块联动,分析体重与运动关系 6. **饮食集成**:与饮食模块联动,分析体重与饮食关系 --- **文档版本**:1.0 **最后更新**:2025-10-20 **作者**:体重管理模块开发组