weight-management-implementation.md 52 KB

体重管理功能实现文档

一、概述

本文档详细说明在 WeightComponent (campus_health_app/frontend/campus-health-app/src/app/modules/weight/weight.component.ts) 中实现完整体重管理系统的技术方案。所有功能在单个组件中实现,使用 ECharts 进行数据可视化,采用 LocalStorage + 后端 API 同步的数据持久化策略。

二、依赖安装

2.1 安装 ECharts 相关依赖

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

import { provideEcharts } from 'ngx-echarts';

export const appConfig: ApplicationConfig = {
  providers: [
    // ... 其他 providers
    provideEcharts(),
  ]
};

三、数据模型设计

3.1 创建数据模型文件

创建 src/app/modules/weight/models/weight.models.ts:

/**
 * 体重记录数据模型
 */
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:

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<WeightRecord[]>([]);
  private goalSubject = new BehaviorSubject<WeightGoal | null>(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<WeightRecord>): 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<WeightRecord[]>(`${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<WeightGoal>(`${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<WeightRecord>(`${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<WeightGoal>(`${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:

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<void>();

  // 数据
  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<WeightRecord> = {
    date: new Date().toISOString().split('T')[0],
    measurementTime: new Date().toTimeString().slice(0, 5),
    measurementCondition: 'fasting',
    tags: []
  };

  // 新目标表单
  newGoal: Partial<WeightGoal> = {};

  // 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 `
            <div style="padding: 8px;">
              <div><strong>${record.date}</strong></div>
              <div>体重: ${record.weight} kg</div>
              <div>体脂率: ${record.bodyFat}%</div>
              <div>测量条件: ${record.measurementCondition === 'fasting' ? '空腹' : '餐后'}</div>
              ${record.notes ? `<div>备注: ${record.notes}</div>` : ''}
            </div>
          `;
        }
      },
      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}<br/>变化: ${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 `
            <div>
              <div><strong>${date}</strong></div>
              <div>体重: ${weight} kg</div>
              <div>体脂率: ${bodyFat}%</div>
            </div>
          `;
        }
      },
      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<WeightRecord>): 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<WeightGoal>): 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:

<div class="weight-container">
  <!-- 头部 -->
  <header class="header">
    <button class="back-btn" (click)="backToDashboard()">
      ← 返回
    </button>
    <h1>体重管理</h1>
    <div class="header-actions">
      <button class="btn btn-primary" (click)="openAddRecordDialog()">
        + 录入体重
      </button>
      <button class="btn btn-secondary" (click)="openGoalDialog()">
        目标设置
      </button>
    </div>
  </header>

  <!-- 统计卡片 -->
  <div class="stats-cards" *ngIf="stats">
    <div class="stat-card">
      <div class="stat-label">当前体重</div>
      <div class="stat-value">{{ stats.currentWeight.toFixed(1) }} <span>kg</span></div>
    </div>
    <div class="stat-card">
      <div class="stat-label">体重变化</div>
      <div class="stat-value" [class.positive]="stats.weightChange > 0" [class.negative]="stats.weightChange < 0">
        {{ stats.weightChange > 0 ? '+' : '' }}{{ stats.weightChange.toFixed(1) }} <span>kg</span>
      </div>
    </div>
    <div class="stat-card">
      <div class="stat-label">平均周变化</div>
      <div class="stat-value" [class.positive]="stats.avgWeeklyChange > 0" [class.negative]="stats.avgWeeklyChange < 0">
        {{ stats.avgWeeklyChange > 0 ? '+' : '' }}{{ stats.avgWeeklyChange.toFixed(2) }} <span>kg/周</span>
      </div>
    </div>
  </div>

  <!-- 主图表区域 -->
  <div class="chart-section">
    <!-- 左侧:趋势图 -->
    <div class="trend-chart-container">
      <div echarts [options]="trendChartOption" class="chart"></div>
      
      <div class="trend-summary" *ngIf="records.length > 0">
        <p>
          当前 {{ 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
        </p>
      </div>
    </div>

    <!-- 右侧:目标进度环形图 -->
    <div class="progress-chart-container" *ngIf="goal">
      <div echarts [options]="progressChartOption" class="chart-small"></div>
    </div>
  </div>

  <!-- 筛选栏 -->
  <div class="filter-bar">
    <button 
      class="filter-btn"
      [class.active]="filter.timePeriod === '7days'"
      (click)="updateTimePeriod('7days')">
      7天
    </button>
    <button 
      class="filter-btn"
      [class.active]="filter.timePeriod === '30days'"
      (click)="updateTimePeriod('30days')">
      30天
    </button>
    <button 
      class="filter-btn"
      [class.active]="filter.timePeriod === '90days'"
      (click)="updateTimePeriod('90days')">
      90天
    </button>
    <button 
      class="filter-btn"
      [class.active]="filter.timePeriod === 'all'"
      (click)="updateTimePeriod('all')">
      全部
    </button>
  </div>

  <!-- 体重变化柱状图 -->
  <div class="change-chart-section">
    <div class="chart-header">
      <button class="toggle-btn" (click)="toggleChartPeriod()">
        切换至{{ chartPeriod === 'weekly' ? '月' : '周' }}视图
      </button>
    </div>
    <div echarts [options]="changeChartOption" class="chart-medium"></div>
  </div>

  <!-- 散点图 -->
  <div class="scatter-chart-section">
    <div echarts [options]="scatterChartOption" class="chart-medium"></div>
  </div>

  <!-- 异常提醒区 -->
  <div class="anomaly-section" *ngIf="anomalies.length > 0">
    <h3>健康提醒</h3>
    <div class="anomaly-list">
      <div 
        *ngFor="let anomaly of anomalies" 
        class="anomaly-item"
        [class.info]="anomaly.severity === 'info'"
        [class.warning]="anomaly.severity === 'warning'"
        [class.danger]="anomaly.severity === 'danger'">
        <span class="anomaly-icon">{{ anomaly.severity === 'info' ? 'ℹ️' : anomaly.severity === 'warning' ? '⚠️' : '❗' }}</span>
        <span class="anomaly-message">{{ anomaly.message }}</span>
      </div>
    </div>
  </div>

  <!-- 添加记录对话框 -->
  <div class="dialog-overlay" *ngIf="showAddRecordDialog" (click)="showAddRecordDialog = false">
    <div class="dialog" (click)="$event.stopPropagation()">
      <h2>录入体重数据</h2>
      
      <div class="form-group">
        <label>日期</label>
        <input type="date" [(ngModel)]="newRecord.date" max="{{ today }}">
      </div>
      
      <div class="form-group">
        <label>测量时间</label>
        <input type="time" [(ngModel)]="newRecord.measurementTime">
      </div>
      
      <div class="form-group">
        <label>体重 (kg)</label>
        <input type="number" [(ngModel)]="newRecord.weight" step="0.1" min="30" max="200">
      </div>
      
      <div class="form-group">
        <label>体脂率 (%)</label>
        <input type="number" [(ngModel)]="newRecord.bodyFat" step="0.1" min="5" max="60">
      </div>
      
      <div class="form-group">
        <label>肌肉量 (kg)</label>
        <input type="number" [(ngModel)]="newRecord.muscleMass" step="0.1" min="10" max="100">
      </div>
      
      <div class="form-group">
        <label>测量条件</label>
        <select [(ngModel)]="newRecord.measurementCondition">
          <option value="fasting">空腹</option>
          <option value="after_meal">餐后</option>
        </select>
      </div>
      
      <div class="form-group">
        <label>备注</label>
        <textarea [(ngModel)]="newRecord.notes" rows="3"></textarea>
      </div>
      
      <div class="dialog-actions">
        <button class="btn btn-secondary" (click)="showAddRecordDialog = false">取消</button>
        <button class="btn btn-primary" (click)="addRecord()">保存</button>
      </div>
    </div>
  </div>

  <!-- 目标设置对话框 -->
  <div class="dialog-overlay" *ngIf="showGoalDialog" (click)="showGoalDialog = false">
    <div class="dialog" (click)="$event.stopPropagation()">
      <h2>设置目标</h2>
      
      <div class="form-group">
        <label>目标体重 (kg)</label>
        <input type="number" [(ngModel)]="newGoal.targetWeight" step="0.1" min="30" max="200">
      </div>
      
      <div class="form-group">
        <label>目标体脂率 (%)</label>
        <input type="number" [(ngModel)]="newGoal.targetBodyFat" step="0.1" min="5" max="60">
      </div>
      
      <div class="form-group">
        <label>目标日期</label>
        <input type="date" [(ngModel)]="newGoal.targetDate" [min]="tomorrow">
      </div>
      
      <div class="dialog-actions">
        <button class="btn btn-secondary" (click)="showGoalDialog = false">取消</button>
        <button class="btn btn-primary" (click)="setGoal()">保存</button>
      </div>
    </div>
  </div>
</div>

七、样式实现

创建 weight.component.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);
    }
  }
}

八、模拟数据生成

为了测试功能,在组件中添加模拟数据生成方法:

// 在 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
作者:体重管理模块开发组