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