本文档详细说明在 WeightComponent
(campus_health_app/frontend/campus-health-app/src/app/modules/weight/weight.component.ts
) 中实现完整体重管理系统的技术方案。所有功能在单个组件中实现,使用 ECharts 进行数据可视化,采用 LocalStorage + 后端 API 同步的数据持久化策略。
cd campus_health_app/frontend/campus-health-app
npm install echarts@5.4.3 --save
npm install ngx-echarts@17.0.0 --save
import { provideEcharts } from 'ngx-echarts';
export const appConfig: ApplicationConfig = {
providers: [
// ... 其他 providers
provideEcharts(),
]
};
创建 src/app/modules/weight/models/weight.models.ts
:
/**
* 体重记录数据模型
*/
export interface WeightRecord {
id?: string; // 记录ID(后端生成)
date: string; // 记录日期 (YYYY-MM-DD)
weight: number; // 体重(kg)
bodyFat: number; // 体脂率(%)
muscleMass: number; // 肌肉含量(kg)
measurementTime?: string; // 测量时间 (HH:mm)
measurementCondition?: 'fasting' | 'after_meal'; // 测量条件:空腹/餐后
notes?: string; // 备注
tags?: string[]; // 关键节点标记(如:开始运动日、目标调整日)
createdAt?: number; // 创建时间戳
updatedAt?: number; // 更新时间戳
syncStatus?: 'synced' | 'pending'; // 同步状态
}
/**
* 体重目标数据模型
*/
export interface WeightGoal {
id?: string; // 目标ID
targetWeight: number; // 目标体重(kg)
targetBodyFat?: number; // 目标体脂率(%)
targetDate: string; // 目标日期 (YYYY-MM-DD)
startWeight: number; // 起始体重(kg)
startBodyFat?: number; // 起始体脂率(%)
startDate: string; // 开始日期 (YYYY-MM-DD)
weeklyTarget?: number; // 每周目标减重量(kg)
createdAt?: number;
updatedAt?: number;
syncStatus?: 'synced' | 'pending';
}
/**
* 异常提醒数据模型
*/
export interface AnomalyAlert {
id: string;
type: 'rapid_change' | 'body_fat_anomaly' | 'missing_data' | 'extreme_value';
severity: 'info' | 'warning' | 'danger';
message: string;
detectedDate: string;
relatedRecordIds?: string[];
}
/**
* LocalStorage 数据结构
*/
export interface WeightLocalData {
records: WeightRecord[];
goal: WeightGoal | null;
pendingSync: {
records: WeightRecord[];
goal: WeightGoal | null;
};
lastSyncTime: number;
}
/**
* 筛选条件
*/
export interface WeightFilter {
timePeriod: '7days' | '30days' | '90days' | 'all' | 'custom';
startDate?: string;
endDate?: string;
condition?: 'all' | 'fasting' | 'after_meal';
tags?: string[];
}
/**
* 统计数据
*/
export interface WeightStats {
currentWeight: number;
weightChange: number;
daysTracked: number;
avgWeeklyChange: number;
bodyFatChange: number;
goalETA: string | null;
}
创建 src/app/services/weight-data.service.ts
:
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, Observable, interval } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
import {
WeightRecord,
WeightGoal,
WeightLocalData,
AnomalyAlert
} from '../modules/weight/models/weight.models';
@Injectable({
providedIn: 'root'
})
export class WeightDataService {
private readonly STORAGE_KEY = 'weight_data';
private readonly API_BASE = '/api/weight';
private readonly SYNC_INTERVAL = 5 * 60 * 1000; // 5分钟
private recordsSubject = new BehaviorSubject<WeightRecord[]>([]);
private goalSubject = new BehaviorSubject<WeightGoal | null>(null);
public records$ = this.recordsSubject.asObservable();
public goal$ = this.goalSubject.asObservable();
constructor(private http: HttpClient) {
this.initializeData();
this.startAutoSync();
}
/**
* 初始化数据:从 localStorage 加载并从后端同步
*/
private initializeData(): void {
const localData = this.getLocalData();
this.recordsSubject.next(localData.records);
this.goalSubject.next(localData.goal);
// 从后端同步最新数据
this.syncFromBackend();
}
/**
* 从 localStorage 获取数据
*/
private getLocalData(): WeightLocalData {
const data = localStorage.getItem(this.STORAGE_KEY);
if (data) {
return JSON.parse(data);
}
return {
records: [],
goal: null,
pendingSync: { records: [], goal: null },
lastSyncTime: 0
};
}
/**
* 保存数据到 localStorage
*/
private saveLocalData(data: WeightLocalData): void {
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(data));
}
/**
* 添加体重记录
*/
addRecord(record: WeightRecord): void {
const localData = this.getLocalData();
// 生成临时ID和时间戳
record.id = record.id || `temp_${Date.now()}`;
record.createdAt = Date.now();
record.syncStatus = 'pending';
// 添加到记录列表
localData.records.unshift(record);
// 添加到待同步队列
localData.pendingSync.records.push(record);
// 保存并更新
this.saveLocalData(localData);
this.recordsSubject.next(localData.records);
}
/**
* 更新体重记录
*/
updateRecord(id: string, updates: Partial<WeightRecord>): void {
const localData = this.getLocalData();
const index = localData.records.findIndex(r => r.id === id);
if (index !== -1) {
localData.records[index] = {
...localData.records[index],
...updates,
updatedAt: Date.now(),
syncStatus: 'pending'
};
// 添加到待同步队列
localData.pendingSync.records.push(localData.records[index]);
this.saveLocalData(localData);
this.recordsSubject.next(localData.records);
}
}
/**
* 删除体重记录
*/
deleteRecord(id: string): void {
const localData = this.getLocalData();
localData.records = localData.records.filter(r => r.id !== id);
this.saveLocalData(localData);
this.recordsSubject.next(localData.records);
// 如果记录已同步到后端,需要调用删除API
if (!id.startsWith('temp_')) {
this.http.delete(`${this.API_BASE}/records/${id}`).subscribe();
}
}
/**
* 设置目标
*/
setGoal(goal: WeightGoal): void {
const localData = this.getLocalData();
goal.createdAt = Date.now();
goal.syncStatus = 'pending';
// 计算每周目标
const daysDiff = this.calculateDaysDiff(goal.startDate, goal.targetDate);
const weightDiff = goal.startWeight - goal.targetWeight;
goal.weeklyTarget = (weightDiff / daysDiff) * 7;
localData.goal = goal;
localData.pendingSync.goal = goal;
this.saveLocalData(localData);
this.goalSubject.next(goal);
}
/**
* 获取目标
*/
getGoal(): WeightGoal | null {
return this.goalSubject.value;
}
/**
* 获取筛选后的记录
*/
getFilteredRecords(filter: any): WeightRecord[] {
let records = this.recordsSubject.value;
// 时间筛选
if (filter.timePeriod !== 'all') {
const now = new Date();
let startDate: Date;
switch (filter.timePeriod) {
case '7days':
startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
break;
case '30days':
startDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
break;
case '90days':
startDate = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
break;
case 'custom':
startDate = filter.startDate ? new Date(filter.startDate) : new Date(0);
break;
default:
startDate = new Date(0);
}
records = records.filter(r => new Date(r.date) >= startDate);
if (filter.timePeriod === 'custom' && filter.endDate) {
const endDate = new Date(filter.endDate);
records = records.filter(r => new Date(r.date) <= endDate);
}
}
// 测量条件筛选
if (filter.condition && filter.condition !== 'all') {
records = records.filter(r => r.measurementCondition === filter.condition);
}
// 标签筛选
if (filter.tags && filter.tags.length > 0) {
records = records.filter(r =>
r.tags && r.tags.some(tag => filter.tags.includes(tag))
);
}
return records;
}
/**
* 从后端同步数据
*/
private syncFromBackend(): void {
// 获取记录
this.http.get<WeightRecord[]>(`${this.API_BASE}/records`)
.pipe(catchError(() => []))
.subscribe(backendRecords => {
const localData = this.getLocalData();
// 合并本地和后端数据(后端数据优先)
const mergedRecords = this.mergeRecords(localData.records, backendRecords);
localData.records = mergedRecords;
localData.lastSyncTime = Date.now();
this.saveLocalData(localData);
this.recordsSubject.next(mergedRecords);
});
// 获取目标
this.http.get<WeightGoal>(`${this.API_BASE}/goal`)
.pipe(catchError(() => null))
.subscribe(backendGoal => {
if (backendGoal) {
const localData = this.getLocalData();
localData.goal = backendGoal;
this.saveLocalData(localData);
this.goalSubject.next(backendGoal);
}
});
}
/**
* 同步到后端
*/
private syncToBackend(): void {
const localData = this.getLocalData();
// 同步待同步的记录
if (localData.pendingSync.records.length > 0) {
localData.pendingSync.records.forEach(record => {
if (record.id?.startsWith('temp_')) {
// 新记录 - POST
this.http.post<WeightRecord>(`${this.API_BASE}/records`, record)
.subscribe(savedRecord => {
this.updateRecordId(record.id!, savedRecord.id!);
});
} else {
// 更新记录 - PUT
this.http.put(`${this.API_BASE}/records/${record.id}`, record)
.subscribe();
}
});
// 清空待同步队列
localData.pendingSync.records = [];
}
// 同步目标
if (localData.pendingSync.goal) {
const goal = localData.pendingSync.goal;
if (goal.id) {
this.http.put(`${this.API_BASE}/goal`, goal).subscribe();
} else {
this.http.post<WeightGoal>(`${this.API_BASE}/goal`, goal)
.subscribe(savedGoal => {
localData.goal = savedGoal;
this.saveLocalData(localData);
this.goalSubject.next(savedGoal);
});
}
localData.pendingSync.goal = null;
}
localData.lastSyncTime = Date.now();
this.saveLocalData(localData);
}
/**
* 启动自动同步
*/
private startAutoSync(): void {
interval(this.SYNC_INTERVAL).subscribe(() => {
this.syncToBackend();
});
}
/**
* 合并本地和后端记录
*/
private mergeRecords(local: WeightRecord[], backend: WeightRecord[]): WeightRecord[] {
const merged = [...backend];
// 添加本地未同步的记录
local.forEach(localRecord => {
if (localRecord.id?.startsWith('temp_')) {
merged.push(localRecord);
}
});
// 按日期排序(最新在前)
return merged.sort((a, b) =>
new Date(b.date).getTime() - new Date(a.date).getTime()
);
}
/**
* 更新记录ID(临时ID -> 后端ID)
*/
private updateRecordId(tempId: string, newId: string): void {
const localData = this.getLocalData();
const record = localData.records.find(r => r.id === tempId);
if (record) {
record.id = newId;
record.syncStatus = 'synced';
this.saveLocalData(localData);
this.recordsSubject.next(localData.records);
}
}
/**
* 计算日期差
*/
private calculateDaysDiff(startDate: string, endDate: string): number {
const start = new Date(startDate);
const end = new Date(endDate);
return Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
}
}
更新 weight.component.ts
:
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router, RouterModule } from '@angular/router';
import { NgxEchartsModule, provideEcharts } from 'ngx-echarts';
import { EChartsOption } from 'echarts';
import { Subject, takeUntil } from 'rxjs';
import { WeightDataService } from '../../services/weight-data.service';
import {
WeightRecord,
WeightGoal,
WeightFilter,
WeightStats,
AnomalyAlert
} from './models/weight.models';
@Component({
selector: 'app-weight',
standalone: true,
imports: [CommonModule, FormsModule, RouterModule, NgxEchartsModule],
templateUrl: './weight.component.html',
styleUrl: './weight.component.scss'
})
export class WeightComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
// 数据
records: WeightRecord[] = [];
goal: WeightGoal | null = null;
filteredRecords: WeightRecord[] = [];
anomalies: AnomalyAlert[] = [];
stats: WeightStats | null = null;
// 筛选条件
filter: WeightFilter = {
timePeriod: '30days',
condition: 'all',
tags: []
};
// UI 状态
showAddRecordDialog = false;
showGoalDialog = false;
chartPeriod: 'weekly' | 'monthly' = 'weekly';
// 新记录表单
newRecord: Partial<WeightRecord> = {
date: new Date().toISOString().split('T')[0],
measurementTime: new Date().toTimeString().slice(0, 5),
measurementCondition: 'fasting',
tags: []
};
// 新目标表单
newGoal: Partial<WeightGoal> = {};
// ECharts 配置
trendChartOption: EChartsOption = {};
changeChartOption: EChartsOption = {};
scatterChartOption: EChartsOption = {};
progressChartOption: EChartsOption = {};
constructor(
private router: Router,
private weightDataService: WeightDataService
) {}
ngOnInit(): void {
// 订阅数据变化
this.weightDataService.records$
.pipe(takeUntil(this.destroy$))
.subscribe(records => {
this.records = records;
this.applyFilter();
this.updateCharts();
this.detectAnomalies();
this.calculateStats();
});
this.weightDataService.goal$
.pipe(takeUntil(this.destroy$))
.subscribe(goal => {
this.goal = goal;
this.updateCharts();
this.calculateStats();
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
/**
* 应用筛选条件
*/
applyFilter(): void {
this.filteredRecords = this.weightDataService.getFilteredRecords(this.filter);
}
/**
* 更新时间筛选
*/
updateTimePeriod(period: '7days' | '30days' | '90days' | 'all'): void {
this.filter.timePeriod = period;
this.applyFilter();
this.updateCharts();
}
/**
* 添加记录
*/
addRecord(): void {
if (this.validateRecord(this.newRecord)) {
this.weightDataService.addRecord(this.newRecord as WeightRecord);
this.resetRecordForm();
this.showAddRecordDialog = false;
}
}
/**
* 设置目标
*/
setGoal(): void {
if (this.validateGoal(this.newGoal)) {
// 设置起始数据
if (this.records.length > 0) {
const latestRecord = this.records[0];
this.newGoal.startWeight = latestRecord.weight;
this.newGoal.startBodyFat = latestRecord.bodyFat;
this.newGoal.startDate = latestRecord.date;
}
this.weightDataService.setGoal(this.newGoal as WeightGoal);
this.resetGoalForm();
this.showGoalDialog = false;
}
}
/**
* 更新所有图表
*/
private updateCharts(): void {
this.updateTrendChart();
this.updateChangeChart();
this.updateScatterChart();
this.updateProgressChart();
}
/**
* 更新趋势折线图
*/
private updateTrendChart(): void {
const records = [...this.filteredRecords].reverse(); // 从旧到新排序
// 准备数据
const dates = records.map(r => r.date);
const weights = records.map(r => r.weight);
// 计划趋势线数据
const plannedWeights = this.calculatePlannedTrend(records);
// 目标线数据
const targetWeights = this.goal
? new Array(dates.length).fill(this.goal.targetWeight)
: [];
// 标注点(关键节点)
const markPoints = this.extractMarkPoints(records);
this.trendChartOption = {
title: {
text: '体重趋势',
left: 'center',
textStyle: { fontSize: 16, fontWeight: 'bold' }
},
tooltip: {
trigger: 'axis',
formatter: (params: any) => {
const index = params[0].dataIndex;
const record = records[index];
return `
<div style="padding: 8px;">
<div><strong>${record.date}</strong></div>
<div>体重: ${record.weight} kg</div>
<div>体脂率: ${record.bodyFat}%</div>
<div>测量条件: ${record.measurementCondition === 'fasting' ? '空腹' : '餐后'}</div>
${record.notes ? `<div>备注: ${record.notes}</div>` : ''}
</div>
`;
}
},
legend: {
data: ['当前体重', '目标体重', '计划趋势'],
bottom: 10
},
grid: {
left: '3%',
right: '4%',
bottom: '15%',
containLabel: true
},
xAxis: {
type: 'category',
data: dates,
boundaryGap: false,
axisLabel: {
formatter: (value: string) => {
const date = new Date(value);
return `${date.getMonth() + 1}/${date.getDate()}`;
}
}
},
yAxis: {
type: 'value',
name: '体重 (kg)',
axisLabel: {
formatter: '{value} kg'
}
},
dataZoom: [
{
type: 'inside',
start: 0,
end: 100
},
{
start: 0,
end: 100,
height: 20,
bottom: 40
}
],
series: [
{
name: '当前体重',
type: 'line',
data: weights,
smooth: true,
itemStyle: { color: '#3b82f6' },
lineStyle: { width: 3 },
markPoint: {
data: markPoints,
symbolSize: 50,
label: {
formatter: '{b}',
fontSize: 10
}
}
},
...(this.goal ? [{
name: '目标体重',
type: 'line',
data: targetWeights,
itemStyle: { color: '#ef4444' },
lineStyle: { width: 2, type: 'dashed' },
symbol: 'none'
}] : []),
...(plannedWeights.length > 0 ? [{
name: '计划趋势',
type: 'line',
data: plannedWeights,
itemStyle: { color: '#9ca3af' },
lineStyle: { width: 2, type: 'dotted' },
symbol: 'none'
}] : [])
]
};
}
/**
* 更新周/月体重变化柱状图
*/
private updateChangeChart(): void {
const changes = this.calculateWeightChanges();
this.changeChartOption = {
title: {
text: this.chartPeriod === 'weekly' ? '周体重变化' : '月体重变化',
left: 'center',
textStyle: { fontSize: 16, fontWeight: 'bold' }
},
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params: any) => {
const value = params[0].value;
const sign = value >= 0 ? '+' : '';
return `${params[0].name}<br/>变化: ${sign}${value.toFixed(2)} kg`;
}
},
grid: {
left: '3%',
right: '4%',
bottom: '10%',
containLabel: true
},
xAxis: {
type: 'category',
data: changes.labels,
axisLabel: {
interval: 0,
rotate: this.chartPeriod === 'weekly' ? 0 : 30
}
},
yAxis: {
type: 'value',
name: '变化量 (kg)',
axisLabel: {
formatter: '{value} kg'
}
},
series: [
{
type: 'bar',
data: changes.values.map(v => ({
value: v,
itemStyle: {
color: v < 0 ? '#10b981' : '#ef4444' // 减重绿色,增重红色
}
})),
label: {
show: true,
position: 'top',
formatter: (params: any) => {
const value = params.value;
const sign = value >= 0 ? '+' : '';
return `${sign}${value.toFixed(1)}kg`;
},
color: '#000'
},
barWidth: '60%'
}
]
};
}
/**
* 更新体重-体脂率散点图
*/
private updateScatterChart(): void {
const data = this.filteredRecords.map(r => [r.weight, r.bodyFat, r.date]);
this.scatterChartOption = {
title: {
text: '体重 vs 体脂率',
left: 'center',
textStyle: { fontSize: 14, fontWeight: 'bold' }
},
tooltip: {
formatter: (params: any) => {
const [weight, bodyFat, date] = params.value;
return `
<div>
<div><strong>${date}</strong></div>
<div>体重: ${weight} kg</div>
<div>体脂率: ${bodyFat}%</div>
</div>
`;
}
},
grid: {
left: '15%',
right: '5%',
bottom: '15%',
top: '20%'
},
xAxis: {
type: 'value',
name: '体重 (kg)',
nameLocation: 'middle',
nameGap: 30
},
yAxis: {
type: 'value',
name: '体脂率 (%)',
nameLocation: 'middle',
nameGap: 40
},
visualMap: {
min: 0,
max: data.length - 1,
dimension: 2,
orient: 'vertical',
right: 10,
top: 'center',
text: ['新', '旧'],
calculable: true,
inRange: {
color: ['#e0e0e0', '#3b82f6']
}
},
series: [
{
type: 'scatter',
data: data.map((d, index) => [...d, index]),
symbolSize: 12,
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
}
}
]
};
}
/**
* 更新目标进度环形图
*/
private updateProgressChart(): void {
if (!this.goal || this.records.length === 0) {
this.progressChartOption = {};
return;
}
const currentWeight = this.records[0].weight;
const totalToLose = this.goal.startWeight - this.goal.targetWeight;
const achieved = this.goal.startWeight - currentWeight;
const progress = Math.min((achieved / totalToLose) * 100, 100);
const remaining = Math.max(this.goal.targetWeight - currentWeight, 0);
// 计算预计完成时间
const eta = this.calculateETA();
this.progressChartOption = {
title: {
text: '目标进度',
left: 'center',
top: 10,
textStyle: { fontSize: 14, fontWeight: 'bold' }
},
series: [
{
type: 'pie',
radius: ['60%', '80%'],
center: ['50%', '55%'],
avoidLabelOverlap: false,
label: {
show: true,
position: 'center',
formatter: () => {
return `{a|${progress.toFixed(0)}%}\n{b|已减 ${achieved.toFixed(1)}kg}\n{c|还需 ${remaining.toFixed(1)}kg}\n{d|${eta}}`;
},
rich: {
a: { fontSize: 24, fontWeight: 'bold', color: '#3b82f6' },
b: { fontSize: 12, color: '#666', padding: [10, 0, 0, 0] },
c: { fontSize: 12, color: '#666', padding: [5, 0, 0, 0] },
d: { fontSize: 10, color: '#999', padding: [5, 0, 0, 0] }
}
},
labelLine: { show: false },
data: [
{
value: progress,
itemStyle: {
color: progress >= 100 ? '#10b981' : progress >= 50 ? '#3b82f6' : '#fbbf24'
}
},
{
value: 100 - progress,
itemStyle: { color: '#e5e7eb' }
}
]
}
]
};
}
/**
* 计算计划趋势线
*/
private calculatePlannedTrend(records: WeightRecord[]): number[] {
if (!this.goal || records.length === 0) return [];
const startDate = new Date(this.goal.startDate);
const endDate = new Date(this.goal.targetDate);
const totalDays = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
const totalWeightToLose = this.goal.startWeight - this.goal.targetWeight;
return records.map(record => {
const currentDate = new Date(record.date);
const daysElapsed = Math.ceil((currentDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
if (daysElapsed < 0) return this.goal!.startWeight;
if (daysElapsed > totalDays) return this.goal!.targetWeight;
return this.goal!.startWeight - (daysElapsed / totalDays) * totalWeightToLose;
});
}
/**
* 提取关键节点标注
*/
private extractMarkPoints(records: WeightRecord[]): any[] {
const markPoints: any[] = [];
records.forEach((record, index) => {
if (record.tags && record.tags.length > 0) {
record.tags.forEach(tag => {
markPoints.push({
name: tag,
coord: [record.date, record.weight],
value: tag,
itemStyle: { color: '#f59e0b' }
});
});
}
});
return markPoints;
}
/**
* 计算周/月体重变化
*/
private calculateWeightChanges(): { labels: string[], values: number[] } {
const records = [...this.filteredRecords].reverse();
const labels: string[] = [];
const values: number[] = [];
if (this.chartPeriod === 'weekly') {
// 按周分组
const weeks: { [key: string]: WeightRecord[] } = {};
records.forEach(record => {
const date = new Date(record.date);
const weekNum = this.getWeekNumber(date);
const weekKey = `第${weekNum}周`;
if (!weeks[weekKey]) weeks[weekKey] = [];
weeks[weekKey].push(record);
});
// 计算每周变化
const weekKeys = Object.keys(weeks).slice(-8); // 最近8周
weekKeys.forEach((weekKey, index) => {
if (index > 0) {
const prevWeek = weekKeys[index - 1];
const prevAvg = this.average(weeks[prevWeek].map(r => r.weight));
const currAvg = this.average(weeks[weekKey].map(r => r.weight));
labels.push(weekKey);
values.push(currAvg - prevAvg);
}
});
} else {
// 按月分组
const months: { [key: string]: WeightRecord[] } = {};
records.forEach(record => {
const date = new Date(record.date);
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
if (!months[monthKey]) months[monthKey] = [];
months[monthKey].push(record);
});
// 计算每月变化
const monthKeys = Object.keys(months).sort().slice(-6); // 最近6个月
monthKeys.forEach((monthKey, index) => {
if (index > 0) {
const prevMonth = monthKeys[index - 1];
const prevAvg = this.average(months[prevMonth].map(r => r.weight));
const currAvg = this.average(months[monthKey].map(r => r.weight));
const [year, month] = monthKey.split('-');
labels.push(`${month}月`);
values.push(currAvg - prevAvg);
}
});
}
return { labels, values };
}
/**
* 计算预计完成时间
*/
private calculateETA(): string {
if (!this.goal || this.records.length < 4) {
return '数据不足';
}
const currentWeight = this.records[0].weight;
const remaining = currentWeight - this.goal.targetWeight;
if (remaining <= 0) {
return '已完成!';
}
// 计算最近4周的平均变化
const last4Weeks = this.records.slice(0, Math.min(this.records.length, 28));
const weeklyChanges: number[] = [];
for (let i = 0; i < last4Weeks.length - 7; i += 7) {
const weekStart = last4Weeks[i].weight;
const weekEnd = last4Weeks[i + 7]?.weight;
if (weekEnd) {
weeklyChanges.push(weekStart - weekEnd);
}
}
const avgWeeklyChange = this.average(weeklyChanges);
if (avgWeeklyChange <= 0) {
return '目标偏离';
}
const weeksRemaining = remaining / avgWeeklyChange;
const eta = new Date();
eta.setDate(eta.getDate() + weeksRemaining * 7);
return `预计 ${eta.getMonth() + 1}月${eta.getDate()}日`;
}
/**
* 异常检测
*/
private detectAnomalies(): void {
this.anomalies = [];
if (this.records.length < 2) return;
// 检测快速体重变化
for (let i = 0; i < this.records.length - 1; i++) {
const curr = this.records[i];
const prev = this.records[i + 1];
const daysDiff = this.calculateDaysDiff(prev.date, curr.date);
const weightDiff = Math.abs(curr.weight - prev.weight);
// 日变化 >0.5kg
if (daysDiff === 1 && weightDiff > 0.5) {
this.anomalies.push({
id: `rapid_${i}`,
type: 'rapid_change',
severity: 'warning',
message: `${curr.date} 体重变化过快(${weightDiff.toFixed(1)}kg/日)`,
detectedDate: curr.date,
relatedRecordIds: [curr.id!, prev.id!]
});
}
// 周变化 >2kg
if (daysDiff >= 6 && daysDiff <= 8 && weightDiff > 2) {
this.anomalies.push({
id: `rapid_week_${i}`,
type: 'rapid_change',
severity: 'danger',
message: `${prev.date} 至 ${curr.date} 体重变化过快(${weightDiff.toFixed(1)}kg/周)`,
detectedDate: curr.date,
relatedRecordIds: [curr.id!, prev.id!]
});
}
// 体脂率异常:体重下降但体脂率上升
if (curr.weight < prev.weight && curr.bodyFat > prev.bodyFat) {
this.anomalies.push({
id: `bodyfat_${i}`,
type: 'body_fat_anomaly',
severity: 'warning',
message: `${curr.date} 体重下降但体脂率上升,可能脱水或肌肉流失`,
detectedDate: curr.date,
relatedRecordIds: [curr.id!]
});
}
}
// 检测缺失数据(>7天未测量)
const now = new Date();
const lastRecord = new Date(this.records[0].date);
const daysSinceLastRecord = Math.ceil((now.getTime() - lastRecord.getTime()) / (1000 * 60 * 60 * 24));
if (daysSinceLastRecord > 7) {
this.anomalies.push({
id: 'missing_data',
type: 'missing_data',
severity: 'info',
message: `已有 ${daysSinceLastRecord} 天未记录体重数据`,
detectedDate: now.toISOString().split('T')[0]
});
}
}
/**
* 计算统计数据
*/
private calculateStats(): void {
if (this.records.length === 0) {
this.stats = null;
return;
}
const current = this.records[0];
const oldest = this.records[this.records.length - 1];
const weightChange = oldest.weight - current.weight;
const bodyFatChange = oldest.bodyFat - current.bodyFat;
const daysTracked = this.calculateDaysDiff(oldest.date, current.date);
// 计算平均周变化(最近4周)
const last4Weeks = this.records.slice(0, Math.min(this.records.length, 28));
let avgWeeklyChange = 0;
if (last4Weeks.length >= 7) {
const weeklyChanges: number[] = [];
for (let i = 0; i < last4Weeks.length - 7; i += 7) {
const weekStart = last4Weeks[i + 7]?.weight;
const weekEnd = last4Weeks[i].weight;
if (weekStart && weekEnd) {
weeklyChanges.push(weekStart - weekEnd);
}
}
avgWeeklyChange = this.average(weeklyChanges);
}
this.stats = {
currentWeight: current.weight,
weightChange,
daysTracked,
avgWeeklyChange,
bodyFatChange,
goalETA: this.calculateETA()
};
}
/**
* 验证记录
*/
private validateRecord(record: Partial<WeightRecord>): boolean {
if (!record.weight || record.weight < 30 || record.weight > 200) {
alert('请输入有效的体重(30-200kg)');
return false;
}
if (!record.bodyFat || record.bodyFat < 5 || record.bodyFat > 60) {
alert('请输入有效的体脂率(5-60%)');
return false;
}
if (!record.muscleMass || record.muscleMass < 10 || record.muscleMass > 100) {
alert('请输入有效的肌肉量(10-100kg)');
return false;
}
return true;
}
/**
* 验证目标
*/
private validateGoal(goal: Partial<WeightGoal>): boolean {
if (!goal.targetWeight) {
alert('请输入目标体重');
return false;
}
if (!goal.targetDate) {
alert('请选择目标日期');
return false;
}
const targetDate = new Date(goal.targetDate);
const today = new Date();
if (targetDate <= today) {
alert('目标日期必须在今天之后');
return false;
}
return true;
}
/**
* 重置记录表单
*/
private resetRecordForm(): void {
this.newRecord = {
date: new Date().toISOString().split('T')[0],
measurementTime: new Date().toTimeString().slice(0, 5),
measurementCondition: 'fasting',
tags: []
};
}
/**
* 重置目标表单
*/
private resetGoalForm(): void {
this.newGoal = {};
}
/**
* 工具方法:计算日期差
*/
private calculateDaysDiff(startDate: string, endDate: string): number {
const start = new Date(startDate);
const end = new Date(endDate);
return Math.ceil(Math.abs((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)));
}
/**
* 工具方法:获取周数
*/
private getWeekNumber(date: Date): number {
const firstDayOfYear = new Date(date.getFullYear(), 0, 1);
const pastDaysOfYear = (date.getTime() - firstDayOfYear.getTime()) / 86400000;
return Math.ceil((pastDaysOfYear + firstDayOfYear.getDay() + 1) / 7);
}
/**
* 工具方法:计算平均值
*/
private average(numbers: number[]): number {
if (numbers.length === 0) return 0;
return numbers.reduce((sum, num) => sum + num, 0) / numbers.length;
}
/**
* 返回仪表盘
*/
backToDashboard(): void {
this.router.navigate(['/dashboard']);
}
/**
* 打开添加记录对话框
*/
openAddRecordDialog(): void {
this.showAddRecordDialog = true;
}
/**
* 打开目标设置对话框
*/
openGoalDialog(): void {
if (this.goal) {
this.newGoal = { ...this.goal };
}
this.showGoalDialog = true;
}
/**
* 切换图表周期
*/
toggleChartPeriod(): void {
this.chartPeriod = this.chartPeriod === 'weekly' ? 'monthly' : 'weekly';
this.updateChangeChart();
}
}
创建 weight.component.html
:
<div class="weight-container">
<!-- 头部 -->
<header class="header">
<button class="back-btn" (click)="backToDashboard()">
← 返回
</button>
<h1>体重管理</h1>
<div class="header-actions">
<button class="btn btn-primary" (click)="openAddRecordDialog()">
+ 录入体重
</button>
<button class="btn btn-secondary" (click)="openGoalDialog()">
目标设置
</button>
</div>
</header>
<!-- 统计卡片 -->
<div class="stats-cards" *ngIf="stats">
<div class="stat-card">
<div class="stat-label">当前体重</div>
<div class="stat-value">{{ stats.currentWeight.toFixed(1) }} <span>kg</span></div>
</div>
<div class="stat-card">
<div class="stat-label">体重变化</div>
<div class="stat-value" [class.positive]="stats.weightChange > 0" [class.negative]="stats.weightChange < 0">
{{ stats.weightChange > 0 ? '+' : '' }}{{ stats.weightChange.toFixed(1) }} <span>kg</span>
</div>
</div>
<div class="stat-card">
<div class="stat-label">平均周变化</div>
<div class="stat-value" [class.positive]="stats.avgWeeklyChange > 0" [class.negative]="stats.avgWeeklyChange < 0">
{{ stats.avgWeeklyChange > 0 ? '+' : '' }}{{ stats.avgWeeklyChange.toFixed(2) }} <span>kg/周</span>
</div>
</div>
</div>
<!-- 主图表区域 -->
<div class="chart-section">
<!-- 左侧:趋势图 -->
<div class="trend-chart-container">
<div echarts [options]="trendChartOption" class="chart"></div>
<div class="trend-summary" *ngIf="records.length > 0">
<p>
当前 {{ records[0].weight.toFixed(1) }}kg,
较 {{ records[records.length - 1].date }}
{{ records[records.length - 1].weight > records[0].weight ? '减少' : '增加' }}
{{ Math.abs(records[records.length - 1].weight - records[0].weight).toFixed(1) }}kg
</p>
</div>
</div>
<!-- 右侧:目标进度环形图 -->
<div class="progress-chart-container" *ngIf="goal">
<div echarts [options]="progressChartOption" class="chart-small"></div>
</div>
</div>
<!-- 筛选栏 -->
<div class="filter-bar">
<button
class="filter-btn"
[class.active]="filter.timePeriod === '7days'"
(click)="updateTimePeriod('7days')">
7天
</button>
<button
class="filter-btn"
[class.active]="filter.timePeriod === '30days'"
(click)="updateTimePeriod('30days')">
30天
</button>
<button
class="filter-btn"
[class.active]="filter.timePeriod === '90days'"
(click)="updateTimePeriod('90days')">
90天
</button>
<button
class="filter-btn"
[class.active]="filter.timePeriod === 'all'"
(click)="updateTimePeriod('all')">
全部
</button>
</div>
<!-- 体重变化柱状图 -->
<div class="change-chart-section">
<div class="chart-header">
<button class="toggle-btn" (click)="toggleChartPeriod()">
切换至{{ chartPeriod === 'weekly' ? '月' : '周' }}视图
</button>
</div>
<div echarts [options]="changeChartOption" class="chart-medium"></div>
</div>
<!-- 散点图 -->
<div class="scatter-chart-section">
<div echarts [options]="scatterChartOption" class="chart-medium"></div>
</div>
<!-- 异常提醒区 -->
<div class="anomaly-section" *ngIf="anomalies.length > 0">
<h3>健康提醒</h3>
<div class="anomaly-list">
<div
*ngFor="let anomaly of anomalies"
class="anomaly-item"
[class.info]="anomaly.severity === 'info'"
[class.warning]="anomaly.severity === 'warning'"
[class.danger]="anomaly.severity === 'danger'">
<span class="anomaly-icon">{{ anomaly.severity === 'info' ? 'ℹ️' : anomaly.severity === 'warning' ? '⚠️' : '❗' }}</span>
<span class="anomaly-message">{{ anomaly.message }}</span>
</div>
</div>
</div>
<!-- 添加记录对话框 -->
<div class="dialog-overlay" *ngIf="showAddRecordDialog" (click)="showAddRecordDialog = false">
<div class="dialog" (click)="$event.stopPropagation()">
<h2>录入体重数据</h2>
<div class="form-group">
<label>日期</label>
<input type="date" [(ngModel)]="newRecord.date" max="{{ today }}">
</div>
<div class="form-group">
<label>测量时间</label>
<input type="time" [(ngModel)]="newRecord.measurementTime">
</div>
<div class="form-group">
<label>体重 (kg)</label>
<input type="number" [(ngModel)]="newRecord.weight" step="0.1" min="30" max="200">
</div>
<div class="form-group">
<label>体脂率 (%)</label>
<input type="number" [(ngModel)]="newRecord.bodyFat" step="0.1" min="5" max="60">
</div>
<div class="form-group">
<label>肌肉量 (kg)</label>
<input type="number" [(ngModel)]="newRecord.muscleMass" step="0.1" min="10" max="100">
</div>
<div class="form-group">
<label>测量条件</label>
<select [(ngModel)]="newRecord.measurementCondition">
<option value="fasting">空腹</option>
<option value="after_meal">餐后</option>
</select>
</div>
<div class="form-group">
<label>备注</label>
<textarea [(ngModel)]="newRecord.notes" rows="3"></textarea>
</div>
<div class="dialog-actions">
<button class="btn btn-secondary" (click)="showAddRecordDialog = false">取消</button>
<button class="btn btn-primary" (click)="addRecord()">保存</button>
</div>
</div>
</div>
<!-- 目标设置对话框 -->
<div class="dialog-overlay" *ngIf="showGoalDialog" (click)="showGoalDialog = false">
<div class="dialog" (click)="$event.stopPropagation()">
<h2>设置目标</h2>
<div class="form-group">
<label>目标体重 (kg)</label>
<input type="number" [(ngModel)]="newGoal.targetWeight" step="0.1" min="30" max="200">
</div>
<div class="form-group">
<label>目标体脂率 (%)</label>
<input type="number" [(ngModel)]="newGoal.targetBodyFat" step="0.1" min="5" max="60">
</div>
<div class="form-group">
<label>目标日期</label>
<input type="date" [(ngModel)]="newGoal.targetDate" [min]="tomorrow">
</div>
<div class="dialog-actions">
<button class="btn btn-secondary" (click)="showGoalDialog = false">取消</button>
<button class="btn btn-primary" (click)="setGoal()">保存</button>
</div>
</div>
</div>
</div>
创建 weight.component.scss
:
.weight-container {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 20px;
}
// 头部
.header {
background: #fff;
padding: 16px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
position: sticky;
top: 0;
z-index: 100;
h1 {
font-size: 20px;
font-weight: bold;
margin: 0;
}
.back-btn {
background: none;
border: none;
font-size: 16px;
cursor: pointer;
color: #3b82f6;
}
.header-actions {
display: flex;
gap: 8px;
}
}
// 按钮
.btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: all 0.3s;
&.btn-primary {
background: #3b82f6;
color: #fff;
&:hover {
background: #2563eb;
}
}
&.btn-secondary {
background: #e5e7eb;
color: #374151;
&:hover {
background: #d1d5db;
}
}
}
// 统计卡片
.stats-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
padding: 16px;
}
.stat-card {
background: #fff;
padding: 16px;
border-radius: 8px;
text-align: center;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
.stat-label {
font-size: 12px;
color: #6b7280;
margin-bottom: 8px;
}
.stat-value {
font-size: 24px;
font-weight: bold;
color: #111827;
span {
font-size: 14px;
font-weight: normal;
color: #6b7280;
}
&.positive {
color: #ef4444;
}
&.negative {
color: #10b981;
}
}
}
// 图表区域
.chart-section {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 16px;
padding: 0 16px;
margin-top: 16px;
}
.trend-chart-container {
background: #fff;
border-radius: 8px;
padding: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
.chart {
width: 100%;
height: 400px;
}
.trend-summary {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #e5e7eb;
text-align: center;
color: #6b7280;
font-size: 14px;
}
}
.progress-chart-container {
background: #fff;
border-radius: 8px;
padding: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
.chart-small {
width: 100%;
height: 300px;
}
}
// 筛选栏
.filter-bar {
display: flex;
gap: 8px;
padding: 16px;
background: #fff;
margin: 16px;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
.filter-btn {
flex: 1;
padding: 10px;
border: 1px solid #e5e7eb;
background: #fff;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s;
&.active {
background: #3b82f6;
color: #fff;
border-color: #3b82f6;
}
&:hover:not(.active) {
background: #f3f4f6;
}
}
}
// 变化图表
.change-chart-section {
background: #fff;
border-radius: 8px;
padding: 16px;
margin: 0 16px 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
.chart-header {
display: flex;
justify-content: flex-end;
margin-bottom: 12px;
.toggle-btn {
background: #e5e7eb;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
&:hover {
background: #d1d5db;
}
}
}
.chart-medium {
width: 100%;
height: 300px;
}
}
// 散点图
.scatter-chart-section {
background: #fff;
border-radius: 8px;
padding: 16px;
margin: 0 16px 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
.chart-medium {
width: 100%;
height: 300px;
}
}
// 异常提醒
.anomaly-section {
background: #fff;
border-radius: 8px;
padding: 16px;
margin: 0 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
h3 {
font-size: 16px;
font-weight: bold;
margin: 0 0 12px 0;
}
.anomaly-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.anomaly-item {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
border-radius: 6px;
font-size: 14px;
&.info {
background: #dbeafe;
color: #1e40af;
}
&.warning {
background: #fef3c7;
color: #92400e;
}
&.danger {
background: #fee2e2;
color: #991b1b;
}
.anomaly-icon {
font-size: 18px;
}
}
}
// 对话框
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
}
.dialog {
background: #fff;
border-radius: 12px;
padding: 24px;
max-width: 500px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
h2 {
font-size: 18px;
font-weight: bold;
margin: 0 0 20px 0;
}
.form-group {
margin-bottom: 16px;
label {
display: block;
font-size: 14px;
font-weight: 500;
margin-bottom: 6px;
color: #374151;
}
input,
select,
textarea {
width: 100%;
padding: 10px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 14px;
&:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
}
textarea {
resize: vertical;
font-family: inherit;
}
}
.dialog-actions {
display: flex;
gap: 12px;
margin-top: 24px;
button {
flex: 1;
}
}
}
// 移动端适配
@media (max-width: 768px) {
.stats-cards {
grid-template-columns: 1fr;
}
.chart-section {
grid-template-columns: 1fr;
}
.progress-chart-container {
order: -1;
}
.header {
flex-wrap: wrap;
h1 {
flex: 1 0 100%;
text-align: center;
margin: 8px 0;
}
.header-actions {
flex: 1;
justify-content: center;
}
}
.filter-bar {
flex-wrap: wrap;
.filter-btn {
flex: 1 1 calc(50% - 4px);
}
}
}
为了测试功能,在组件中添加模拟数据生成方法:
// 在 WeightComponent 中添加
generateMockData(): void {
const mockRecords: WeightRecord[] = [];
const startDate = new Date();
startDate.setDate(startDate.getDate() - 90);
let weight = 70;
for (let i = 0; i < 90; i++) {
const date = new Date(startDate);
date.setDate(date.getDate() + i);
// 模拟体重波动
weight += (Math.random() - 0.55) * 0.3;
// 每周三添加标签
const tags: string[] = [];
if (date.getDay() === 3 && i % 14 === 0) {
tags.push(i === 14 ? '开始运动' : '目标调整');
}
mockRecords.push({
id: `mock_${i}`,
date: date.toISOString().split('T')[0],
weight: Math.round(weight * 10) / 10,
bodyFat: Math.round((20 + Math.random() * 3) * 10) / 10,
muscleMass: Math.round((28 + Math.random() * 2) * 10) / 10,
measurementTime: '08:00',
measurementCondition: i % 2 === 0 ? 'fasting' : 'after_meal',
tags,
createdAt: date.getTime(),
syncStatus: 'synced'
});
}
// 保存模拟数据
const localData = {
records: mockRecords.reverse(),
goal: {
targetWeight: 65,
targetBodyFat: 18,
targetDate: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
startWeight: 70,
startBodyFat: 22,
startDate: mockRecords[mockRecords.length - 1].date,
weeklyTarget: 0.5
} as WeightGoal,
pendingSync: { records: [], goal: null },
lastSyncTime: Date.now()
};
localStorage.setItem('weight_data', JSON.stringify(localData));
window.location.reload();
}
后端需要提供以下接口:
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 }
GET /api/weight/goal
WeightGoal
POST /api/weight/goal
WeightGoal
WeightGoal
PUT /api/weight/goal
WeightGoal
WeightGoal
文档版本:1.0
最后更新:2025-10-20
作者:体重管理模块开发组