瀏覽代碼

feat(weight): 增强体重管理模块功能与样式

- 添加 ECharts 依赖以支持数据可视化
- 更新体重管理组件,增加体重趋势、变化和散点图
- 优化用户界面,增强交互体验,包括添加记录和目标设置对话框
- 实现标签筛选和自定义日期范围功能
- 更新样式以提升整体视觉效果
17846405080 9 小時之前
父節點
當前提交
a7753aeec4

+ 2073 - 0
campus_health_app/docs/weight-management-implementation.md

@@ -0,0 +1,2073 @@
+# 体重管理功能实现文档
+
+## 一、概述
+
+本文档详细说明在 `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<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`:
+
+```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<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`:
+
+```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`:
+
+```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  
+**作者**:体重管理模块开发组
+

+ 237 - 0
campus_health_app/docs/weight-page-troubleshooting.md

@@ -0,0 +1,237 @@
+# 体重管理页面问题诊断与修复报告
+
+## 问题描述
+访问 `http://localhost:4200/weight` 时自动跳转到登录页面。
+
+## 根本原因分析
+
+经过深入分析,发现以下问题:
+
+### 1. ✅ ECharts CDN冲突(已修复)
+**问题**:在 `index.html` 中通过CDN引入了ECharts,同时npm也安装了echarts包,导致模块加载冲突。
+
+**修复**:
+- 移除了 `index.html` 中的CDN引入
+- 在 `app.config.ts` 中正确配置ngx-echarts使用npm安装的echarts
+
+```typescript
+// app.config.ts
+importProvidersFrom(
+  NgxEchartsModule.forRoot({
+    echarts: () => import('echarts')
+  })
+)
+```
+
+### 2. ✅ NgxEchartsModule配置缺失(已修复)
+**问题**:NgxEchartsModule没有正确注册到应用配置中。
+
+**修复**:在 `app.config.ts` 中添加了正确的provider配置。
+
+### 3. 可能的原因:组件加载错误
+
+如果页面仍然跳转到登录页,可能是因为:
+
+#### A. 开发服务器未重启
+Angular开发服务器需要重启才能加载新的配置更改。
+
+**解决方案**:
+```bash
+# 停止当前服务器 (Ctrl+C)
+# 重新启动
+cd campus_health_app/frontend/campus-health-app
+npm start
+```
+
+#### B. 浏览器缓存问题
+旧的JavaScript可能仍在缓存中。
+
+**解决方案**:
+1. 打开浏览器开发者工具 (F12)
+2. 右键点击刷新按钮
+3. 选择"清空缓存并硬性重新加载"
+
+或者:
+- Chrome: Ctrl+Shift+Delete 清除缓存
+- 勾选"缓存的图片和文件"
+- 时间范围选择"全部时间"
+- 点击"清除数据"
+
+#### C. 路由懒加载错误
+如果weight模块加载失败,Angular会触发通配符路由重定向到login。
+
+**调试方法**:
+1. 打开浏览器控制台 (F12)
+2. 访问 `http://localhost:4200/weight`
+3. 查看是否有错误信息,特别是:
+   - Module loading errors
+   - Component initialization errors
+   - ECharts loading errors
+
+**预期的控制台输出**:
+```
+✅ WeightComponent initialized
+📊 Starting data subscriptions
+📝 Received weight records: 0
+```
+
+#### D. TypeScript编译错误
+如果组件有编译错误,会导致模块无法加载。
+
+**检查方法**:
+```bash
+cd campus_health_app/frontend/campus-health-app
+npm run build
+```
+
+查看是否有ERROR输出。
+
+## 修复步骤清单
+
+### 步骤1:重启开发服务器 ⭐
+```bash
+# 在运行npm start的终端按 Ctrl+C 停止
+# 然后重新启动
+cd campus_health_app/frontend/campus-health-app
+npm start
+```
+
+### 步骤2:清除浏览器缓存
+1. 按 F12 打开开发者工具
+2. 在Network面板勾选"Disable cache"
+3. 刷新页面 (Ctrl+Shift+R)
+
+### 步骤3:验证路由加载
+访问测试页面:
+```
+file:///D:/workspace/Agi/zrd-nb666/campus_health_app/frontend/campus-health-app/test-routes.html
+```
+
+点击"体重管理"链接,观察:
+- 是否跳转到weight页面
+- 控制台是否有错误
+- Network面板中模块是否成功加载
+
+### 步骤4:使用一键生成测试数据
+成功进入weight页面后:
+1. 向下滚动到页面底部
+2. 点击"一键生成90天模拟数据"按钮
+3. 页面会重新加载并显示完整的图表
+
+## 验证清单
+
+- [ ] 开发服务器已重启
+- [ ] 浏览器缓存已清除
+- [ ] 访问 `http://localhost:4200/weight` 不跳转
+- [ ] 控制台显示 "✅ WeightComponent initialized"
+- [ ] 页面显示统计卡片和图表
+- [ ] 点击"录入体重"按钮显示对话框
+- [ ] 点击"一键生成90天模拟数据"能生成数据
+
+## 额外调试技巧
+
+### 查看路由日志
+在浏览器控制台输入:
+```javascript
+// 查看当前路由状态
+console.log(window.location);
+
+// 查看localStorage中的数据
+console.log(localStorage.getItem('weight_data'));
+```
+
+### 手动导航测试
+在控制台输入:
+```javascript
+// 假设你在其他页面,尝试编程式导航
+window.location.href = '/weight';
+```
+
+### 检查模块加载
+在Network面板中筛选:
+- 类型选择"JS"
+- 查找"weight"相关的chunk文件
+- 查看是否有404或加载失败
+
+## 预期结果
+
+成功修复后,访问 `http://localhost:4200/weight` 应该看到:
+
+1. **顶部导航栏**:
+   - ← 返回按钮
+   - 体重管理标题
+   - + 录入体重 按钮
+   - 目标设置 按钮
+
+2. **统计卡片(3个)**:
+   - 当前体重
+   - 体重变化
+   - 平均周变化
+
+3. **主图表区域**:
+   - 左侧:体重趋势折线图(空白或有数据)
+   - 右侧:目标进度环形图(如果设置了目标)
+
+4. **筛选栏**:
+   - 7天/30天/90天/全部/自定义按钮
+   - 测量条件下拉框
+
+5. **其他图表**:
+   - 周/月体重变化柱状图
+   - 体重vs体脂率散点图
+
+6. **底部**:
+   - 一键生成90天模拟数据按钮
+
+## 常见错误及解决方案
+
+### 错误1:Cannot read properties of undefined
+**原因**:数据服务初始化失败
+**解决**:确保WeightDataService正确注入
+
+### 错误2:ECharts is not defined
+**原因**:ECharts模块加载失败
+**解决**:
+1. 确认npm install成功安装echarts
+2. 确认app.config.ts中NgxEchartsModule配置正确
+3. 重启开发服务器
+
+### 错误3:Router navigation failed
+**原因**:路由配置错误
+**解决**:检查weight.routes.ts导出是否正确
+
+### 错误4:页面空白但无错误
+**原因**:CSS未加载或样式文件过大
+**解决**:
+1. 检查weight.component.scss是否存在
+2. 检查构建警告(文件大小超出预算)
+3. 优化样式文件大小
+
+## 技术支持
+
+如果问题仍然存在,请提供以下信息:
+
+1. 浏览器控制台完整错误信息
+2. Network面板中失败的请求
+3. npm start的完整输出
+4. npm run build的输出
+
+## 文件清单
+
+已修改的文件:
+- ✅ `src/index.html` - 移除ECharts CDN
+- ✅ `src/app/app.config.ts` - 添加NgxEchartsModule配置
+- ✅ `src/app/modules/weight/weight.component.ts` - 添加调试日志
+- ✅ `src/app/modules/weight/weight.component.html` - 完整模板
+- ✅ `src/app/modules/weight/weight.component.scss` - 响应式样式
+- ✅ `src/app/modules/weight/models/weight.models.ts` - 数据模型
+- ✅ `src/app/services/weight-data.service.ts` - 数据服务
+
+## 更新日志
+
+**2025-10-20**
+- 修复ECharts CDN冲突
+- 正确配置NgxEchartsModule
+- 添加调试日志
+- 创建故障排除文档
+

+ 45 - 0
campus_health_app/frontend/campus-health-app/package-lock.json

@@ -17,8 +17,10 @@
         "@angular/router": "^20.3.0",
         "@angular/ssr": "^20.3.3",
         "chart.js": "^4.5.0",
+        "echarts": "^5.4.3",
         "express": "^5.1.0",
         "ng2-charts": "^8.0.0",
+        "ngx-echarts": "^17.0.0",
         "rxjs": "~7.8.0",
         "tslib": "^2.3.0",
         "zone.js": "^0.15.1"
@@ -5088,6 +5090,22 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/echarts": {
+      "version": "5.4.3",
+      "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.4.3.tgz",
+      "integrity": "sha512-mYKxLxhzy6zyTi/FaEbJMOZU1ULGEQHaeIeuMR5L+JnJTpz+YR03mnnpBhbR4+UYJAgiXgpyTVLffPAjOTLkZA==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "tslib": "2.3.0",
+        "zrender": "5.4.4"
+      }
+    },
+    "node_modules/echarts/node_modules/tslib": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
+      "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
+      "license": "0BSD"
+    },
     "node_modules/ee-first": {
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -7581,6 +7599,18 @@
         "rxjs": "^6.5.3 || ^7.4.0"
       }
     },
+    "node_modules/ngx-echarts": {
+      "version": "17.0.0",
+      "resolved": "https://registry.npmjs.org/ngx-echarts/-/ngx-echarts-17.0.0.tgz",
+      "integrity": "sha512-kKwOf+L1iEkEKYdVF/b9yV6KieUkptUTpqofk5i0Ul8N30oYCcdzwEirLdX/diasxi1k+j0kEk3LlTwfT2ERYw==",
+      "license": "MIT",
+      "dependencies": {
+        "tslib": "^2.3.0"
+      },
+      "peerDependencies": {
+        "echarts": ">=5.0.0"
+      }
+    },
     "node_modules/node-addon-api": {
       "version": "6.1.0",
       "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz",
@@ -10142,6 +10172,21 @@
       "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz",
       "integrity": "sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==",
       "license": "MIT"
+    },
+    "node_modules/zrender": {
+      "version": "5.4.4",
+      "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.4.4.tgz",
+      "integrity": "sha512-0VxCNJ7AGOMCWeHVyTrGzUgrK4asT4ml9PEkeGirAkKNYXYzoPJCLvmyfdoOXcjTHPs10OZVMfD1Rwg16AZyYw==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "tslib": "2.3.0"
+      }
+    },
+    "node_modules/zrender/node_modules/tslib": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
+      "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
+      "license": "0BSD"
     }
   }
 }

+ 2 - 0
campus_health_app/frontend/campus-health-app/package.json

@@ -32,8 +32,10 @@
     "@angular/router": "^20.3.0",
     "@angular/ssr": "^20.3.3",
     "chart.js": "^4.5.0",
+    "echarts": "^5.4.3",
     "express": "^5.1.0",
     "ng2-charts": "^8.0.0",
+    "ngx-echarts": "^17.0.0",
     "rxjs": "~7.8.0",
     "tslib": "^2.3.0",
     "zone.js": "^0.15.1"

+ 34 - 0
campus_health_app/frontend/campus-health-app/restart-dev-server.bat

@@ -0,0 +1,34 @@
+@echo off
+echo ========================================
+echo    重启 Angular 开发服务器
+echo ========================================
+echo.
+echo 正在停止现有的服务器进程...
+taskkill /F /IM node.exe /FI "WINDOWTITLE eq npm*" 2>nul
+timeout /t 2 /nobreak >nul
+
+echo.
+echo 清理缓存...
+if exist ".angular" (
+    rmdir /s /q ".angular"
+    echo .angular 缓存已清除
+)
+
+echo.
+echo 正在启动开发服务器...
+echo 请稍候,服务器启动需要几秒钟...
+echo.
+echo ========================================
+echo  提示:服务器启动后,请访问以下地址
+echo  http://localhost:4200/weight
+echo ========================================
+echo.
+
+start npm start
+
+echo.
+echo 开发服务器正在启动...
+echo 请在新窗口中查看启动进度
+echo.
+pause
+

+ 8 - 2
campus_health_app/frontend/campus-health-app/src/app/app.config.ts

@@ -1,6 +1,7 @@
-import { ApplicationConfig } from '@angular/core';
+import { ApplicationConfig, importProvidersFrom } from '@angular/core';
 import { provideRouter } from '@angular/router';
 import { provideHttpClient } from '@angular/common/http';
+import { NgxEchartsModule } from 'ngx-echarts';
 
 import { routes } from './app.routes';
 import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
@@ -9,6 +10,11 @@ export const appConfig: ApplicationConfig = {
   providers: [
     provideRouter(routes),
     provideHttpClient(),
-    provideClientHydration(withEventReplay())
+    provideClientHydration(withEventReplay()),
+    importProvidersFrom(
+      NgxEchartsModule.forRoot({
+        echarts: () => import('echarts')
+      })
+    )
   ]
 };

+ 88 - 0
campus_health_app/frontend/campus-health-app/src/app/modules/weight/models/weight.models.ts

@@ -0,0 +1,88 @@
+/**
+ * 体重记录数据模型
+ */
+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;
+}
+
+
+
+
+
+

+ 180 - 61
campus_health_app/frontend/campus-health-app/src/app/modules/weight/weight.component.html

@@ -1,76 +1,195 @@
 <div class="weight-container">
-  <!-- 头部导航 -->
-  <header class="weight-header">
-    <button class="back-button" (click)="backToDashboard()">← 返回</button>
+  <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>
 
-  <!-- 主内容区域 -->
-  <main class="weight-main">
-    <!-- 当前体重概览 -->
-    <div class="weight-overview">
-      <h2>当前体重信息</h2>
-      <div class="current-stats">
-        <div class="stat-card">
-          <div class="stat-icon">⚖️</div>
-          <div class="stat-content">
-            <span class="stat-label">体重</span>
-            <span class="stat-value">{{ latestWeight }} kg</span>
+  <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>
+    <button class="filter-btn" [class.active]="filter.timePeriod === 'custom'" (click)="showCustomDatePicker = !showCustomDatePicker">自定义</button>
+    <select class="filter-select" [(ngModel)]="filter.condition" (change)="applyFilter(); updateCharts();">
+      <option value="all">全部条件</option>
+      <option value="fasting">空腹</option>
+      <option value="after_meal">餐后</option>
+    </select>
+  </div>
+
+  <!-- 自定义日期范围选择器 -->
+  <div class="custom-date-picker" *ngIf="showCustomDatePicker">
+    <div class="date-range-inputs">
+      <div class="date-input-group">
+        <label>开始日期</label>
+        <input type="date" [(ngModel)]="filter.startDate" />
+      </div>
+      <div class="date-input-group">
+        <label>结束日期</label>
+        <input type="date" [(ngModel)]="filter.endDate" />
+      </div>
+      <button class="btn btn-primary" (click)="applyCustomDateRange()">应用</button>
+    </div>
+  </div>
+
+  <!-- 标签筛选 -->
+  <div class="tag-filter" *ngIf="availableTags.length > 0">
+    <div class="tag-filter-title">按标签筛选:</div>
+    <div class="tag-filter-buttons">
+      <button *ngFor="let tag of availableTags" class="tag-filter-btn" [class.active]="filter.tags?.includes(tag)" (click)="toggleFilterTag(tag)">
+        {{ tag }}
+      </button>
+    </div>
+  </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="form-group">
+        <label>标签(关键节点标记)</label>
+        <div class="tag-selector">
+          <div class="available-tags">
+            <button *ngFor="let tag of availableTags" type="button" class="tag-btn" [class.selected]="newRecord.tags?.includes(tag)" (click)="addTagToRecord(tag)">
+              {{ tag }}
+            </button>
           </div>
-        </div>
-        <div class="stat-card">
-          <div class="stat-icon">🧍</div>
-          <div class="stat-content">
-            <span class="stat-label">体脂率</span>
-            <span class="stat-value">{{ latestBodyFat }}%</span>
+          <div class="selected-tags" *ngIf="newRecord.tags && newRecord.tags.length > 0">
+            <span class="selected-tag" *ngFor="let tag of newRecord.tags">
+              {{ tag }}
+              <button type="button" class="remove-tag" (click)="removeTagFromRecord(tag)">×</button>
+            </span>
           </div>
-        </div>
-        <div class="stat-card">
-          <div class="stat-icon">💪</div>
-          <div class="stat-content">
-            <span class="stat-label">肌肉含量</span>
-            <span class="stat-value">{{ latestMuscleMass }} kg</span>
+          <div class="custom-tag-input">
+            <input type="text" [(ngModel)]="newTagInput" placeholder="添加自定义标签" (keyup.enter)="addCustomTag()" />
+            <button type="button" class="btn btn-secondary btn-sm" (click)="addCustomTag()">添加</button>
           </div>
         </div>
       </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="weight-records">
-      <h2>历史体重记录</h2>
-      <div class="records-container">
-        <@for (record of weightRecords; track record.date) {
-          <div class="record-card">
-            <div class="record-header">
-              <span class="record-date">{{ record.date }}</span>
-            </div>
-            <div class="record-details">
-              <div class="detail-item">
-                <span class="detail-label">体重</span>
-                <span class="detail-value weight-value">{{ record.weight }} kg</span>
-              </div>
-              <div class="detail-item">
-                <span class="detail-label">体脂率</span>
-                <span class="detail-value body-fat-value">{{ record.bodyFat }}%</span>
-              </div>
-              <div class="detail-item">
-                <span class="detail-label">肌肉含量</span>
-                <span class="detail-value muscle-value">{{ record.muscleMass }} kg</span>
-              </div>
-            </div>
-          </div>
-        } @empty {
-          <div class="no-records">
-            <p>暂无体重记录</p>
-          </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 class="weight-tips">
-      <h3>体重管理建议</h3>
-      <p>保持规律的测量频率,建议每周同一时间记录一次体重。结合合理饮食和适量运动,逐步达到健康体重目标。</p>
-    </div>
-  </main>
+  <!-- 调试:一键生成90天模拟数据 -->
+  <div style="padding: 16px; text-align: center;">
+    <button class="btn btn-secondary" (click)="generateMockData()">一键生成90天模拟数据</button>
+  </div>
 </div>

+ 173 - 235
campus_health_app/frontend/campus-health-app/src/app/modules/weight/weight.component.scss

@@ -1,273 +1,211 @@
-// Weight组件样式
-
 .weight-container {
-  display: flex;
-  flex-direction: column;
-  height: 100vh;
-  background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
-  color: #333;
+  min-height: 100vh;
+  background: #f5f5f5;
+  padding-bottom: 20px;
 }
 
-// 头部导航
-.weight-header {
+.header {
+  background: #fff;
+  padding: 16px;
   display: flex;
   align-items: center;
-  padding: 1.5rem 2rem;
-  background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);
-  color: white;
-  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
-}
-
-.back-button {
-  background: rgba(255, 255, 255, 0.1);
-  border: 1px solid rgba(255, 255, 255, 0.2);
-  border-radius: 12px;
-  padding: 0.75rem 1rem;
-  font-size: 1rem;
-  cursor: pointer;
-  margin-right: 1.5rem;
-  color: white;
-  transition: all 0.3s ease;
-  backdrop-filter: blur(10px);
-
-  &:hover {
-    background: rgba(255, 255, 255, 0.2);
-    transform: translateY(-2px);
-    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+  justify-content: space-between;
+  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
+  position: sticky;
+  top: 0;
+  z-index: 10;
+
+  h1 {
+    font-size: 20px;
+    font-weight: bold;
+    margin: 0;
   }
-}
 
-.weight-header h1 {
-  margin: 0;
-  font-size: 1.8rem;
-  color: white;
-  font-weight: 700;
-}
+  .back-btn {
+    background: none;
+    border: none;
+    font-size: 16px;
+    cursor: pointer;
+    color: #3b82f6;
+  }
 
-// 主内容区域
-.weight-main {
-  flex: 1;
-  padding: 2rem;
-  overflow-y: auto;
+  .header-actions {
+    display: flex;
+    gap: 8px;
+  }
 }
 
-// 体重概览区域
-.weight-overview {
-  background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
-  border-radius: 20px;
-  padding: 2rem;
-  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
-  margin-bottom: 2rem;
-  border: 1px solid rgba(226, 232, 240, 0.5);
-}
+.btn {
+  padding: 8px 16px;
+  border: none;
+  border-radius: 8px;
+  font-size: 14px;
+  cursor: pointer;
+  transition: all 0.2s ease;
 
-.weight-overview h2 {
-  margin: 0 0 1.5rem 0;
-  font-size: 1.5rem;
-  color: #1e293b;
-  font-weight: 700;
-  background: linear-gradient(135deg, #1e293b 0%, #6366f1 100%);
-  -webkit-background-clip: text;
-  -webkit-text-fill-color: transparent;
-  background-clip: text;
+  &.btn-primary {
+    background: #3b82f6;
+    color: #fff;
+    &:hover { background: #2563eb; }
+  }
+  &.btn-secondary {
+    background: #e5e7eb;
+    color: #374151;
+    &:hover { background: #d1d5db; }
+  }
 }
 
-.current-stats {
+.stats-cards {
   display: grid;
-  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
-  gap: 1.5rem;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 12px;
+  padding: 16px;
 }
 
 .stat-card {
-  background: white;
-  border-radius: 16px;
-  padding: 1.5rem;
-  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
-  border: 1px solid rgba(226, 232, 240, 0.5);
-  display: flex;
-  align-items: center;
-  gap: 1rem;
-  transition: transform 0.3s ease, box-shadow 0.3s ease;
+  background: #fff;
+  padding: 16px;
+  border-radius: 10px;
+  text-align: center;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
 
-  &:hover {
-    transform: translateY(-4px);
-    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
+  .stat-label {
+    font-size: 12px;
+    color: #6b7280;
+    margin-bottom: 8px;
   }
+  .stat-value {
+    font-size: 24px;
+    font-weight: bold;
+    color: #111827;
+    span { font-size: 14px; color: #6b7280; font-weight: 500; }
+  }
+  .positive { color: #ef4444; }
+  .negative { color: #10b981; }
 }
 
-.stat-icon {
-  font-size: 2.5rem;
-}
-
-.stat-content {
-  flex: 1;
-}
-
-.stat-label {
-  display: block;
-  color: #64748b;
-  font-size: 0.95rem;
-  font-weight: 500;
-  margin-bottom: 0.5rem;
-}
-
-.stat-value {
-  display: block;
-  font-size: 1.8rem;
-  font-weight: 700;
-  color: #4f46e5;
-}
-
-// 历史体重记录列表
-.weight-records {
-  margin-bottom: 2rem;
+.chart-section {
+  display: grid;
+  grid-template-columns: 2fr 1fr;
+  gap: 16px;
+  padding: 0 16px;
+  margin-top: 8px;
+}
+
+.trend-chart-container {
+  background: #fff;
+  border-radius: 10px;
+  padding: 16px;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
+  .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;
+  }
 }
 
-.weight-records h2 {
-  margin: 0 0 1.5rem 0;
-  font-size: 1.5rem;
-  color: #1e293b;
-  font-weight: 700;
-  background: linear-gradient(135deg, #1e293b 0%, #6366f1 100%);
-  -webkit-background-clip: text;
-  -webkit-text-fill-color: transparent;
-  background-clip: text;
+.progress-chart-container {
+  background: #fff;
+  border-radius: 10px;
+  padding: 16px;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
+  .chart-small { width: 100%; height: 300px; }
 }
 
-.records-container {
+.filter-bar {
   display: flex;
-  flex-direction: column;
-  gap: 1.2rem;
-}
-
-.record-card {
-  background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
-  border-radius: 16px;
-  padding: 1.5rem;
-  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
-  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
-  border: 1px solid rgba(226, 232, 240, 0.5);
-  position: relative;
-  overflow: hidden;
-
-  &::before {
-    content: '';
-    position: absolute;
-    top: 0;
-    left: 0;
-    right: 0;
-    height: 4px;
-    background: linear-gradient(90deg, #6366f1 0%, #4f46e5 100%);
-    transform: scaleX(0);
-    transition: transform 0.3s ease;
+  gap: 8px;
+  padding: 16px;
+  background: #fff;
+  margin: 16px;
+  border-radius: 10px;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
+  .filter-btn {
+    flex: 1; padding: 10px; border: 1px solid #e5e7eb; background: #fff; border-radius: 8px; cursor: pointer;
+    &.active { background: #3b82f6; color: #fff; border-color: #3b82f6; }
+    &:hover:not(.active) { background: #f3f4f6; }
   }
-
-  &:hover {
-    transform: translateY(-4px);
-    box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
-    
-    &::before {
-      transform: scaleX(1);
-    }
+  .filter-select {
+    padding: 10px; border: 1px solid #e5e7eb; border-radius: 8px; background: #fff; min-width: 120px;
   }
 }
 
-.record-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 1rem;
-}
-
-.record-date {
-  color: #202124;
-  font-size: 1.1rem;
-  font-weight: 600;
-}
-
-.record-details {
-  display: grid;
-  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
-  gap: 1rem;
-  padding-top: 1rem;
-  border-top: 1px solid #f1f3f4;
-}
-
-.detail-item {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-}
-
-.detail-label {
-  color: #64748b;
-  font-size: 0.95rem;
-  font-weight: 500;
-}
-
-.detail-value {
-  color: #202124;
-  font-size: 1rem;
-  font-weight: 600;
-}
-
-.weight-value {
-  color: #4f46e5;
-}
-
-.body-fat-value {
-  color: #ec4899;
-}
-
-.muscle-value {
-  color: #10b981;
-}
-
-.no-records {
-  text-align: center;
-  padding: 4rem 2rem;
-  color: #64748b;
+.change-chart-section, .scatter-chart-section {
+  background: #fff; border-radius: 10px; padding: 16px; margin: 0 16px 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.08);
+  .chart-header { display: flex; justify-content: flex-end; margin-bottom: 12px; }
+  .toggle-btn { background: #e5e7eb; border: none; padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 12px; }
+  .chart-medium { width: 100%; height: 300px; }
+}
+
+.anomaly-section {
+  background: #fff; border-radius: 10px; padding: 16px; margin: 0 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.08);
+  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: 8px; font-size: 14px; }
+  .anomaly-item.info { background: #dbeafe; color: #1e40af; }
+  .anomaly-item.warning { background: #fef3c7; color: #92400e; }
+  .anomaly-item.danger { background: #fee2e2; color: #991b1b; }
+  .anomaly-icon { font-size: 18px; }
+}
+
+.dialog-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 50; padding: 20px; }
+.dialog { background: #fff; border-radius: 12px; padding: 24px; max-width: 520px; width: 100%; max-height: 90vh; overflow-y: auto; }
+.dialog h2 { font-size: 18px; font-weight: bold; margin: 0 0 16px 0; }
+.form-group { margin-bottom: 14px; }
+.form-group label { display: block; font-size: 14px; font-weight: 500; margin-bottom: 6px; color: #374151; }
+.form-group input, .form-group select, .form-group textarea { width: 100%; padding: 10px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 14px; }
+.dialog-actions { display: flex; gap: 12px; margin-top: 18px; }
+
+// 标签选择器
+.tag-selector {
+  .available-tags { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
+  .tag-btn { padding: 6px 12px; border: 1px solid #d1d5db; background: #fff; border-radius: 6px; cursor: pointer; font-size: 13px; transition: all 0.2s;
+    &:hover { background: #f3f4f6; }
+    &.selected { background: #3b82f6; color: #fff; border-color: #3b82f6; }
+  }
+  .selected-tags { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
+  .selected-tag { display: inline-flex; align-items: center; gap: 4px; padding: 4px 8px; background: #dbeafe; color: #1e40af; border-radius: 6px; font-size: 13px;
+    .remove-tag { background: none; border: none; color: #1e40af; font-size: 18px; cursor: pointer; padding: 0 4px; line-height: 1; }
+  }
+  .custom-tag-input { display: flex; gap: 8px; margin-top: 12px;
+    input { flex: 1; }
+    .btn-sm { padding: 6px 12px; font-size: 13px; }
+  }
 }
 
-// 健康建议区域
-.weight-tips {
-  background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
-  border-radius: 20px;
-  padding: 2rem;
-  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
-  border: 1px solid rgba(226, 232, 240, 0.5);
-  position: relative;
-  overflow: hidden;
-
-  &::before {
-    content: '';
-    position: absolute;
-    top: 0;
-    left: 0;
-    right: 0;
-    height: 4px;
-    background: linear-gradient(90deg, #10b981 0%, #059669 100%);
+// 自定义日期选择器
+.custom-date-picker { background: #fff; padding: 16px; margin: 0 16px 16px; border-radius: 10px; box-shadow: 0 1px 3px rgba(0,0,0,0.08);
+  .date-range-inputs { display: flex; gap: 12px; align-items: end;
+    .date-input-group { flex: 1;
+      label { display: block; font-size: 13px; font-weight: 500; margin-bottom: 6px; color: #374151; }
+      input { width: 100%; padding: 8px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 14px; }
+    }
   }
 }
 
-.weight-tips h3 {
-  margin: 0 0 1.5rem 0;
-  font-size: 1.3rem;
-  color: #1e293b;
-  font-weight: 700;
-  background: linear-gradient(135deg, #1e293b 0%, #10b981 100%);
-  -webkit-background-clip: text;
-  -webkit-text-fill-color: transparent;
-  background-clip: text;
+// 标签筛选
+.tag-filter { background: #fff; padding: 16px; margin: 0 16px 16px; border-radius: 10px; box-shadow: 0 1px 3px rgba(0,0,0,0.08);
+  .tag-filter-title { font-size: 14px; font-weight: 500; margin-bottom: 12px; color: #374151; }
+  .tag-filter-buttons { display: flex; flex-wrap: wrap; gap: 8px; }
+  .tag-filter-btn { padding: 6px 12px; border: 1px solid #e5e7eb; background: #fff; border-radius: 6px; cursor: pointer; font-size: 13px; transition: all 0.2s;
+    &:hover { background: #f3f4f6; }
+    &.active { background: #3b82f6; color: #fff; border-color: #3b82f6; }
+  }
 }
 
-.weight-tips p {
-  margin: 0;
-  color: #64748b;
-  font-size: 1rem;
-  line-height: 1.6;
-  padding: 1rem;
-  background: linear-gradient(135deg, #f0fdf4 0%, #ecfdf5 100%);
-  border-radius: 12px;
-  border-left: 4px solid #10b981;
+@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; }
+  .header h1 { flex: 1 0 100%; text-align: center; margin: 8px 0; }
+  .header .header-actions { flex: 1; justify-content: center; }
+  .filter-bar { flex-wrap: wrap; }
+  .filter-bar .filter-btn { flex: 1 1 calc(50% - 4px); }
+  .filter-bar .filter-select { flex: 1 0 100%; margin-top: 8px; }
+  .custom-date-picker .date-range-inputs { flex-direction: column; align-items: stretch;
+    button { margin-top: 8px; }
+  }
+  .tag-filter .tag-filter-buttons { gap: 6px; }
+  .tag-filter .tag-filter-btn { flex: 1 1 auto; min-width: fit-content; }
 }

+ 1045 - 32
campus_health_app/frontend/campus-health-app/src/app/modules/weight/weight.component.ts

@@ -1,53 +1,1066 @@
-import { Component } from '@angular/core';
+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 } from 'ngx-echarts';
+import type { EChartsOption, SeriesOption } from 'echarts';
+import { Subject, takeUntil } from 'rxjs';
 
-// 体重记录数据模型
-interface WeightRecord {
-  date: string; // 记录日期
-  weight: number; // 体重(kg)
-  bodyFat: number; // 体脂率(%)
-  muscleMass: number; // 肌肉含量(kg)
-}
+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, RouterModule],
+  imports: [CommonModule, FormsModule, RouterModule, NgxEchartsModule],
   templateUrl: './weight.component.html',
   styleUrl: './weight.component.scss'
 })
-export class WeightComponent {
-  // 历史体重记录数据(模拟数据)
-  weightRecords: WeightRecord[] = [
-    { date: '2025-10-13', weight: 65.0, bodyFat: 22.5, muscleMass: 28.5 },
-    { date: '2025-10-10', weight: 65.2, bodyFat: 22.7, muscleMass: 28.3 },
-    { date: '2025-10-07', weight: 65.5, bodyFat: 23.0, muscleMass: 28.0 },
-    { date: '2025-10-04', weight: 65.8, bodyFat: 23.2, muscleMass: 27.8 },
-    { date: '2025-10-01', weight: 66.0, bodyFat: 23.5, muscleMass: 27.5 },
-    { date: '2025-09-28', weight: 66.2, bodyFat: 23.8, muscleMass: 27.3 },
-    { date: '2025-09-25', weight: 66.5, bodyFat: 24.0, muscleMass: 27.0 }
-  ];
+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;
+  showCustomDatePicker = false;
+  chartPeriod: 'weekly' | 'monthly' = 'weekly';
+
+  // 可用标签列表
+  availableTags = ['开始运动', '目标调整', '饮食改变', '生病', '假期', '压力期'];
+  
+  // 新记录表单
+  newRecord: Partial<WeightRecord> = {
+    date: new Date().toISOString().split('T')[0],
+    measurementTime: new Date().toTimeString().slice(0, 5),
+    measurementCondition: 'fasting',
+    tags: []
+  };
+  
+  // 新标签输入
+  newTagInput = '';
+
+  // 新目标表单
+  newGoal: Partial<WeightGoal> = {};
+
+  // 辅助属性
+  today = new Date().toISOString().split('T')[0];
+  tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().split('T')[0];
+  Math = Math;
+
+  // ECharts 配置
+  trendChartOption: EChartsOption = {};
+  changeChartOption: EChartsOption = {};
+  scatterChartOption: EChartsOption = {};
+  progressChartOption: EChartsOption = {};
+
+  constructor(
+    private router: Router,
+    private weightDataService: WeightDataService
+  ) {}
+
+  ngOnInit(): void {
+    console.log('✅ WeightComponent initialized');
+    console.log('📊 Starting data subscriptions');
+    
+    // 订阅数据变化
+    this.weightDataService.records$
+      .pipe(takeUntil(this.destroy$))
+      .subscribe(records => {
+        console.log('📝 Received weight records:', records.length);
+        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;
+    }
+  }
+
+  /**
+   * 更新所有图表
+   */
+  updateCharts(): void {
+    this.updateTrendChart();
+    this.updateChangeChart();
+    this.updateScatterChart();
+    this.updateProgressChart();
+  }
+
+  /**
+   * 更新趋势折线图
+   */
+  private updateTrendChart(): void {
+    const records = [...this.filteredRecords].reverse(); // 从旧到新排序
+
+    if (records.length === 0) {
+      this.trendChartOption = {};
+      return;
+    }
+
+    // 准备数据
+    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', color: '#1f2937' }
+      },
+      tooltip: {
+        trigger: 'axis',
+        backgroundColor: 'rgba(255, 255, 255, 0.95)',
+        borderColor: '#e5e7eb',
+        borderWidth: 1,
+        textStyle: { color: '#374151' },
+        formatter: (params: any) => {
+          const index = params[0].dataIndex;
+          const record = records[index];
+          return `
+            <div style="padding: 8px; font-size: 13px;">
+              <div style="font-weight: bold; margin-bottom: 6px; color: #111827;">${record.date}</div>
+              <div style="margin-bottom: 4px;"><span style="color: #3b82f6;">●</span> 体重: ${record.weight} kg</div>
+              <div style="margin-bottom: 4px;">体脂率: ${record.bodyFat}%</div>
+              <div>测量条件: ${record.measurementCondition === 'fasting' ? '空腹' : '餐后'}</div>
+              ${record.notes ? `<div style="margin-top: 4px; color: #6b7280;">备注: ${record.notes}</div>` : ''}
+            </div>
+          `;
+        }
+      },
+      legend: {
+        data: ['当前体重', '目标体重', '计划趋势'],
+        bottom: 10,
+        textStyle: { fontSize: 12 }
+      },
+      grid: {
+        left: '3%',
+        right: '4%',
+        bottom: '15%',
+        top: '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()}`;
+          },
+          fontSize: 11
+        }
+      },
+      yAxis: {
+        type: 'value',
+        name: '体重 (kg)',
+        nameTextStyle: { fontSize: 12 },
+        axisLabel: {
+          formatter: '{value}',
+          fontSize: 11
+        }
+      },
+      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 },
+          areaStyle: {
+            color: {
+              type: 'linear',
+              x: 0,
+              y: 0,
+              x2: 0,
+              y2: 1,
+              colorStops: [
+                { offset: 0, color: 'rgba(59, 130, 246, 0.3)' },
+                { offset: 1, color: 'rgba(59, 130, 246, 0.05)' }
+              ]
+            }
+          },
+          markPoint: markPoints.length > 0 ? {
+            data: markPoints,
+            symbolSize: 50,
+            label: {
+              formatter: '{b}',
+              fontSize: 10
+            }
+          } : undefined
+        },
+        ...(this.goal ? [{
+          name: '目标体重',
+          type: 'line',
+          data: targetWeights,
+          itemStyle: { color: '#ef4444' },
+          lineStyle: { width: 2, type: 'dashed' },
+          symbol: 'none'
+        }] as any[] : []),
+        ...(plannedWeights.length > 0 ? [{
+          name: '计划趋势',
+          type: 'line',
+          data: plannedWeights,
+          itemStyle: { color: '#9ca3af' },
+          lineStyle: { width: 2, type: 'dotted' },
+          symbol: 'none'
+        }] as any[] : [])
+      ] as unknown as SeriesOption[]
+    };
+  }
+
+  /**
+   * 更新周/月体重变化柱状图
+   */
+  private updateChangeChart(): void {
+    const changes = this.calculateWeightChanges();
+    
+    if (changes.labels.length === 0) {
+      this.changeChartOption = {};
+      return;
+    }
+    
+    this.changeChartOption = {
+      title: {
+        text: this.chartPeriod === 'weekly' ? '周体重变化' : '月体重变化',
+        left: 'center',
+        textStyle: { fontSize: 16, fontWeight: 'bold', color: '#1f2937' }
+      },
+      tooltip: {
+        trigger: 'axis',
+        axisPointer: { type: 'shadow' },
+        backgroundColor: 'rgba(255, 255, 255, 0.95)',
+        borderColor: '#e5e7eb',
+        borderWidth: 1,
+        formatter: (params: any) => {
+          const value = params[0].value;
+          const sign = value >= 0 ? '+' : '';
+          const label = value >= 0 ? '增重' : '减重';
+          return `
+            <div style="padding: 8px;">
+              <div style="font-weight: bold; margin-bottom: 4px;">${params[0].name}</div>
+              <div>变化: ${sign}${value.toFixed(2)} kg</div>
+              <div style="color: ${value >= 0 ? '#ef4444' : '#10b981'};">${label}</div>
+            </div>
+          `;
+        }
+      },
+      grid: {
+        left: '3%',
+        right: '4%',
+        bottom: '10%',
+        top: '15%',
+        containLabel: true
+      },
+      xAxis: {
+        type: 'category',
+        data: changes.labels,
+        axisLabel: {
+          interval: 0,
+          rotate: this.chartPeriod === 'weekly' ? 0 : 30,
+          fontSize: 11
+        }
+      },
+      yAxis: {
+        type: 'value',
+        name: '变化量 (kg)',
+        nameTextStyle: { fontSize: 12 },
+        axisLabel: {
+          formatter: '{value}',
+          fontSize: 11
+        }
+      },
+      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',
+            fontSize: 11,
+            fontWeight: 'bold'
+          },
+          barWidth: '60%'
+        }
+      ]
+    };
+  }
+
+  /**
+   * 更新体重-体脂率散点图
+   */
+  private updateScatterChart(): void {
+    if (this.filteredRecords.length === 0) {
+      this.scatterChartOption = {};
+      return;
+    }
+
+    const data = this.filteredRecords.map((r, index) => [r.weight, r.bodyFat, r.date, index]);
+    
+    this.scatterChartOption = {
+      title: {
+        text: '体重 vs 体脂率',
+        left: 'center',
+        textStyle: { fontSize: 14, fontWeight: 'bold', color: '#1f2937' }
+      },
+      tooltip: {
+        formatter: (params: any) => {
+          const [weight, bodyFat, date] = params.value;
+          return `
+            <div style="padding: 8px;">
+              <div style="font-weight: bold; margin-bottom: 4px;">${date}</div>
+              <div>体重: ${weight} kg</div>
+              <div>体脂率: ${bodyFat}%</div>
+            </div>
+          `;
+        },
+        backgroundColor: 'rgba(255, 255, 255, 0.95)',
+        borderColor: '#e5e7eb',
+        borderWidth: 1
+      },
+      grid: {
+        left: '15%',
+        right: '10%',
+        bottom: '15%',
+        top: '20%'
+      },
+      xAxis: {
+        type: 'value',
+        name: '体重 (kg)',
+        nameLocation: 'middle',
+        nameGap: 30,
+        nameTextStyle: { fontSize: 12 },
+        axisLabel: { fontSize: 11 }
+      },
+      yAxis: {
+        type: 'value',
+        name: '体脂率 (%)',
+        nameLocation: 'middle',
+        nameGap: 40,
+        nameTextStyle: { fontSize: 12 },
+        axisLabel: { fontSize: 11 }
+      },
+      visualMap: {
+        min: 0,
+        max: data.length - 1,
+        dimension: 3,
+        orient: 'vertical',
+        right: 10,
+        top: 'center',
+        text: ['新', '旧'],
+        calculable: true,
+        textStyle: { fontSize: 11 },
+        inRange: {
+          color: ['#d1d5db', '#3b82f6']
+        }
+      },
+      series: [
+        {
+          type: 'scatter',
+          data: data,
+          symbolSize: 14,
+          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(Math.max((achieved / totalToLose) * 100, 0), 100);
+    const remaining = Math.max(currentWeight - this.goal.targetWeight, 0);
+
+    // 计算预计完成时间
+    const eta = this.calculateETA();
+
+    // 动态颜色
+    let color = '#fbbf24'; // 黄色
+    if (progress >= 100) {
+      color = '#10b981'; // 绿色
+    } else if (progress >= 50) {
+      color = '#3b82f6'; // 蓝色
+    }
+
+    this.progressChartOption = {
+      title: {
+        text: '目标进度',
+        left: 'center',
+        top: 10,
+        textStyle: { fontSize: 14, fontWeight: 'bold', color: '#1f2937' }
+      },
+      series: [
+        {
+          type: 'pie',
+          radius: ['55%', '75%'],
+          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: color, lineHeight: 30 },
+              b: { fontSize: 12, color: '#666', padding: [8, 0, 0, 0], lineHeight: 18 },
+              c: { fontSize: 12, color: '#666', padding: [4, 0, 0, 0], lineHeight: 18 },
+              d: { fontSize: 10, color: '#999', padding: [4, 0, 0, 0], lineHeight: 16 }
+            }
+          },
+          labelLine: { show: false },
+          data: [
+            {
+              value: progress,
+              itemStyle: { color: color }
+            },
+            {
+              value: 100 - progress,
+              itemStyle: { color: '#e5e7eb' }
+            }
+          ],
+          emphasis: {
+            scale: false
+          }
+        }
+      ]
+    };
+  }
+
+  /**
+   * 计算计划趋势线
+   */
+  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) => {
+      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 (records.length < 2) {
+      return { labels, values };
+    }
+
+    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);
+      });
 
-  // 最近一次记录的体重
-  get latestWeight(): number {
-    return this.weightRecords.length > 0 ? this.weightRecords[0].weight : 0;
+      // 计算每周变化
+      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 [, 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: []
+    };
   }
 
-  // 最近一次记录的体脂率
-  get latestBodyFat(): number {
-    return this.weightRecords.length > 0 ? this.weightRecords[0].bodyFat : 0;
+  /**
+   * 重置目标表单
+   */
+  private resetGoalForm(): void {
+    this.newGoal = {};
   }
 
-  // 最近一次记录的肌肉含量
-  get latestMuscleMass(): number {
-    return this.weightRecords.length > 0 ? this.weightRecords[0].muscleMass : 0;
+  /**
+   * 工具方法:计算日期差
+   */
+  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)));
   }
 
-  constructor(private router: Router) {}
+  /**
+   * 工具方法:获取周数
+   */
+  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.resetRecordForm();
+    this.showAddRecordDialog = true;
+  }
+
+  /**
+   * 打开目标设置对话框
+   */
+  openGoalDialog(): void {
+    if (this.goal) {
+      this.newGoal = { ...this.goal };
+    } else {
+      this.newGoal = {};
+    }
+    this.showGoalDialog = true;
+  }
+
+  /**
+   * 切换图表周期
+   */
+  toggleChartPeriod(): void {
+    this.chartPeriod = this.chartPeriod === 'weekly' ? 'monthly' : 'weekly';
+    this.updateChangeChart();
+  }
+
+  /**
+   * 添加标签到记录
+   */
+  addTagToRecord(tag: string): void {
+    if (!this.newRecord.tags) {
+      this.newRecord.tags = [];
+    }
+    if (!this.newRecord.tags.includes(tag)) {
+      this.newRecord.tags.push(tag);
+    }
+  }
+
+  /**
+   * 从记录移除标签
+   */
+  removeTagFromRecord(tag: string): void {
+    if (this.newRecord.tags) {
+      this.newRecord.tags = this.newRecord.tags.filter(t => t !== tag);
+    }
+  }
+
+  /**
+   * 添加自定义标签
+   */
+  addCustomTag(): void {
+    const tag = this.newTagInput.trim();
+    if (tag && !this.availableTags.includes(tag)) {
+      this.availableTags.push(tag);
+      this.addTagToRecord(tag);
+      this.newTagInput = '';
+    } else if (tag) {
+      this.addTagToRecord(tag);
+      this.newTagInput = '';
+    }
+  }
+
+  /**
+   * 切换标签筛选
+   */
+  toggleFilterTag(tag: string): void {
+    if (!this.filter.tags) {
+      this.filter.tags = [];
+    }
+    const index = this.filter.tags.indexOf(tag);
+    if (index > -1) {
+      this.filter.tags.splice(index, 1);
+    } else {
+      this.filter.tags.push(tag);
+    }
+    this.applyFilter();
+    this.updateCharts();
+  }
+
+  /**
+   * 应用自定义日期范围
+   */
+  applyCustomDateRange(): void {
+    this.filter.timePeriod = 'custom';
+    this.showCustomDatePicker = false;
+    this.applyFilter();
+    this.updateCharts();
+  }
+
+  /**
+   * 生成模拟数据(用于演示)
+   */
+  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() === 1 && 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((23 - i * 0.03 + Math.random() * 0.5) * 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: any = {
+      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: 23,
+        startDate: mockRecords[mockRecords.length - 1].date,
+        weeklyTarget: 0.5
+      },
+      pendingSync: { records: [], goal: null },
+      lastSyncTime: Date.now()
+    };
+    
+    localStorage.setItem('weight_data', JSON.stringify(localData));
+    window.location.reload();
+  }
+}

+ 354 - 0
campus_health_app/frontend/campus-health-app/src/app/services/weight-data.service.ts

@@ -0,0 +1,354 @@
+import { Injectable } from '@angular/core';
+import { HttpClient } from '@angular/common/http';
+import { BehaviorSubject, Observable, interval, of } from 'rxjs';
+import { catchError } from 'rxjs/operators';
+import {
+  WeightRecord,
+  WeightGoal,
+  WeightLocalData,
+  WeightFilter
+} 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}`)
+        .pipe(catchError(() => []))
+        .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: WeightFilter): 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 as WeightRecord[]);
+        
+        localData.records = mergedRecords;
+        localData.lastSyncTime = Date.now();
+        
+        this.saveLocalData(localData);
+        this.recordsSubject.next(mergedRecords);
+      });
+    
+    // 获取目标
+    this.http.get<WeightGoal>(`${this.API_BASE}/goal`)
+      .pipe(catchError(() => of(null as unknown as WeightGoal)))
+      .subscribe(backendGoal => {
+        if (backendGoal) {
+          const localData = this.getLocalData();
+          localData.goal = backendGoal as WeightGoal;
+          this.saveLocalData(localData);
+          this.goalSubject.next(backendGoal as WeightGoal);
+        }
+      });
+  }
+
+  /**
+   * 同步到后端
+   */
+  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)
+            .pipe(catchError(() => []))
+            .subscribe(savedRecord => {
+              if (savedRecord && (savedRecord as any).id) {
+                this.updateRecordId(record.id!, (savedRecord as any).id);
+              }
+            });
+        } else {
+          // 更新记录 - PUT
+          this.http.put(`${this.API_BASE}/records/${record.id}`, record)
+            .pipe(catchError(() => []))
+            .subscribe();
+        }
+      });
+      
+      // 清空待同步队列
+      localData.pendingSync.records = [];
+    }
+    
+    // 同步目标
+    if (localData.pendingSync.goal) {
+      const goal = localData.pendingSync.goal;
+      
+      if (goal.id) {
+        this.http.put(`${this.API_BASE}/goal`, goal)
+          .pipe(catchError(() => of(null)))
+          .subscribe();
+      } else {
+        this.http.post<WeightGoal>(`${this.API_BASE}/goal`, goal)
+          .pipe(catchError(() => of(null as unknown as WeightGoal)))
+          .subscribe(savedGoal => {
+            if (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));
+  }
+}
+
+
+
+
+

+ 1 - 1
campus_health_app/frontend/campus-health-app/src/index.html

@@ -1,5 +1,5 @@
 <!doctype html>
-<html lang="en">
+<html lang="zh-CN">
 <head>
   <meta charset="utf-8">
   <title>CampusHealthApp</title>

+ 64 - 0
campus_health_app/frontend/campus-health-app/test-routes.html

@@ -0,0 +1,64 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>路由测试</title>
+    <style>
+        body {
+            font-family: Arial, sans-serif;
+            max-width: 800px;
+            margin: 50px auto;
+            padding: 20px;
+        }
+        .route-link {
+            display: block;
+            padding: 15px;
+            margin: 10px 0;
+            background: #3b82f6;
+            color: white;
+            text-decoration: none;
+            border-radius: 8px;
+            text-align: center;
+            transition: background 0.3s;
+        }
+        .route-link:hover {
+            background: #2563eb;
+        }
+        h1 {
+            color: #1f2937;
+        }
+        .info {
+            background: #dbeafe;
+            padding: 15px;
+            border-radius: 8px;
+            margin-bottom: 20px;
+            color: #1e40af;
+        }
+    </style>
+</head>
+<body>
+    <h1>🧪 路由测试页面</h1>
+    
+    <div class="info">
+        <strong>测试说明:</strong>
+        <p>点击下面的链接测试各个路由是否正常工作。如果某个路由跳转到登录页面,说明该路由存在问题。</p>
+    </div>
+
+    <a href="http://localhost:4200/login" class="route-link">🔐 登录页面</a>
+    <a href="http://localhost:4200/dashboard" class="route-link">📊 仪表盘</a>
+    <a href="http://localhost:4200/weight" class="route-link">⚖️ 体重管理(测试目标)</a>
+    <a href="http://localhost:4200/diet" class="route-link">🍎 饮食管理</a>
+    <a href="http://localhost:4200/exercise" class="route-link">🏃 运动管理</a>
+    <a href="http://localhost:4200/monitoring" class="route-link">💓 健康监测</a>
+    <a href="http://localhost:4200/knowledge" class="route-link">📚 健康知识</a>
+    <a href="http://localhost:4200/school-services" class="route-link">🏫 校园服务</a>
+    <a href="http://localhost:4200/user-center" class="route-link">👤 个人中心</a>
+
+    <script>
+        console.log('🔍 路由测试页面已加载');
+        console.log('📍 当前位置:', window.location.href);
+    </script>
+</body>
+</html>
+