Selaa lähdekoodia

feat(weight): 迁移体重管理模块至 ECharts 进行数据可视化

- 更新体重管理组件以使用 ECharts 替代 Chart.js,提升图表性能和可定制性
- 添加图表实例的初始化和销毁逻辑,确保在窗口调整大小时图表自适应
- 优化图表更新逻辑,确保数据变化时图表实时反映
- 更新样式以增强用户体验和视觉效果
17846405080 1 kuukausi sitten
vanhempi
commit
f2ede3d1e9
31 muutettua tiedostoa jossa 2349 lisäystä ja 409 poistoa
  1. 1 0
      campus_health_app/backend/src/controllers/WeightController.ts
  2. 1 0
      campus_health_app/backend/src/dto/tag.dto.ts
  3. 1 0
      campus_health_app/backend/src/dto/weight-goal.dto.ts
  4. 1 0
      campus_health_app/backend/src/dto/weight-record.dto.ts
  5. 1 0
      campus_health_app/backend/src/entities/AnomalyLog.ts
  6. 1 0
      campus_health_app/backend/src/entities/Tag.ts
  7. 1 0
      campus_health_app/backend/src/entities/User.ts
  8. 1 0
      campus_health_app/backend/src/entities/WeightGoal.ts
  9. 1 0
      campus_health_app/backend/src/entities/WeightRecord.ts
  10. 1 0
      campus_health_app/backend/src/entities/WeightRecordTag.ts
  11. 1 0
      campus_health_app/backend/src/middlewares/auth.middleware.ts
  12. 1 0
      campus_health_app/backend/src/middlewares/error.middleware.ts
  13. 1 0
      campus_health_app/backend/src/middlewares/logger.middleware.ts
  14. 1 0
      campus_health_app/backend/src/routes/index.ts
  15. 1 0
      campus_health_app/backend/src/routes/weight.routes.ts
  16. 1 0
      campus_health_app/backend/src/server.ts
  17. 1 0
      campus_health_app/backend/src/services/StatsService.ts
  18. 1 0
      campus_health_app/backend/src/services/TagService.ts
  19. 1 0
      campus_health_app/backend/src/services/WeightGoalService.ts
  20. 1 0
      campus_health_app/backend/src/services/WeightRecordService.ts
  21. 120 61
      campus_health_app/frontend/campus-health-app/src/app/modules/monitoring/monitoring.component.html
  22. 0 0
      campus_health_app/frontend/campus-health-app/src/app/modules/monitoring/monitoring.component.scss
  23. 327 164
      campus_health_app/frontend/campus-health-app/src/app/modules/monitoring/monitoring.component.ts
  24. 38 17
      campus_health_app/frontend/campus-health-app/src/app/modules/weight/weight.component.html
  25. 509 89
      campus_health_app/frontend/campus-health-app/src/app/modules/weight/weight.component.scss
  26. 346 78
      campus_health_app/frontend/campus-health-app/src/app/modules/weight/weight.component.ts
  27. 80 0
      campus_health_app/frontend/campus-health-app/src/app/shared/components/modal/modal.component.html
  28. 708 0
      campus_health_app/frontend/campus-health-app/src/app/shared/components/modal/modal.component.scss
  29. 165 0
      campus_health_app/frontend/campus-health-app/src/app/shared/components/modal/modal.component.ts
  30. 34 0
      campus_health_app/frontend/campus-health-app/src/app/shared/components/modal/modal.config.ts
  31. 2 0
      campus_health_app/frontend/campus-health-app/src/index.html

+ 1 - 0
campus_health_app/backend/src/controllers/WeightController.ts

@@ -375,3 +375,4 @@ export class WeightController {
 
 
 
+

+ 1 - 0
campus_health_app/backend/src/dto/tag.dto.ts

@@ -26,3 +26,4 @@ export class CreateTagDto {
 
 
 
+

+ 1 - 0
campus_health_app/backend/src/dto/weight-goal.dto.ts

@@ -51,3 +51,4 @@ export class UpdateWeightGoalDto {
 
 
 
+

+ 1 - 0
campus_health_app/backend/src/dto/weight-record.dto.ts

@@ -122,3 +122,4 @@ export class WeightRecordQueryDto {
 
 
 
+

+ 1 - 0
campus_health_app/backend/src/entities/AnomalyLog.ts

@@ -80,3 +80,4 @@ export class AnomalyLog {
 
 
 
+

+ 1 - 0
campus_health_app/backend/src/entities/Tag.ts

@@ -67,3 +67,4 @@ export class Tag {
 
 
 
+

+ 1 - 0
campus_health_app/backend/src/entities/User.ts

@@ -69,3 +69,4 @@ export class User {
 
 
 
+

+ 1 - 0
campus_health_app/backend/src/entities/WeightGoal.ts

@@ -87,3 +87,4 @@ export class WeightGoal {
 
 
 
+

+ 1 - 0
campus_health_app/backend/src/entities/WeightRecord.ts

@@ -85,3 +85,4 @@ export class WeightRecord {
 
 
 
+

+ 1 - 0
campus_health_app/backend/src/entities/WeightRecordTag.ts

@@ -52,3 +52,4 @@ export class WeightRecordTag {
 
 
 
+

+ 1 - 0
campus_health_app/backend/src/middlewares/auth.middleware.ts

@@ -41,3 +41,4 @@ declare global {
 
 
 
+

+ 1 - 0
campus_health_app/backend/src/middlewares/error.middleware.ts

@@ -47,3 +47,4 @@ export const notFoundMiddleware = (
 
 
 
+

+ 1 - 0
campus_health_app/backend/src/middlewares/logger.middleware.ts

@@ -34,3 +34,4 @@ export const loggerMiddleware = (
 
 
 
+

+ 1 - 0
campus_health_app/backend/src/routes/index.ts

@@ -28,3 +28,4 @@ export default router;
 
 
 
+

+ 1 - 0
campus_health_app/backend/src/routes/weight.routes.ts

@@ -67,3 +67,4 @@ export default router;
 
 
 
+

+ 1 - 0
campus_health_app/backend/src/server.ts

@@ -127,3 +127,4 @@ export default app;
 
 
 
+

+ 1 - 0
campus_health_app/backend/src/services/StatsService.ts

@@ -109,3 +109,4 @@ export class StatsService {
 
 
 
+

+ 1 - 0
campus_health_app/backend/src/services/TagService.ts

@@ -83,3 +83,4 @@ export class TagService {
 
 
 
+

+ 1 - 0
campus_health_app/backend/src/services/WeightGoalService.ts

@@ -136,3 +136,4 @@ export class WeightGoalService {
 
 
 
+

+ 1 - 0
campus_health_app/backend/src/services/WeightRecordService.ts

@@ -242,3 +242,4 @@ export class WeightRecordService {
 
 
 
+

+ 120 - 61
campus_health_app/frontend/campus-health-app/src/app/modules/monitoring/monitoring.component.html

@@ -109,7 +109,7 @@
           <div class="chart-card">
             <h3>心率趋势</h3>
             <div class="chart-wrapper">
-              <canvas id="heartRateChart"></canvas>
+              <div id="heartRateChart" style="width: 100%; height: 100%;"></div>
             </div>
           </div>
 
@@ -117,7 +117,7 @@
           <div class="chart-card">
             <h3>血压趋势</h3>
             <div class="chart-wrapper">
-              <canvas id="bloodPressureChart"></canvas>
+              <div id="bloodPressureChart" style="width: 100%; height: 100%;"></div>
             </div>
           </div>
 
@@ -125,7 +125,7 @@
           <div class="chart-card">
             <h3>血氧趋势</h3>
             <div class="chart-wrapper">
-              <canvas id="bloodOxygenChart"></canvas>
+              <div id="bloodOxygenChart" style="width: 100%; height: 100%;"></div>
             </div>
           </div>
 
@@ -133,7 +133,7 @@
           <div class="chart-card correlation-chart">
             <h3>健康指标关联分析</h3>
             <div class="chart-wrapper">
-              <canvas id="correlationChart"></canvas>
+              <div id="correlationChart" style="width: 100%; height: 100%;"></div>
             </div>
             <p class="chart-description">心率与收缩压的相关性分析,帮助了解身体状况的整体表现</p>
           </div>
@@ -343,14 +343,16 @@
 </div>
 
 <!-- 数据记录模态框 -->
-@if (showRecordModal) {
-<div class="modal">
-  <div class="modal-content">
-    <div class="modal-header">
-      <h3>{{ recordForm.type === 'heartRate' ? '记录心率' : recordForm.type === 'bloodPressure' ? '记录血压' : '记录血氧' }}</h3>
-      <button class="close-btn" (click)="closeRecordModal()">×</button>
-    </div>
-    <div class="modal-body">
+<app-modal 
+  [(isOpen)]="showRecordModal"
+  [config]="{
+    title: recordForm.type === 'heartRate' ? '记录心率' : recordForm.type === 'bloodPressure' ? '记录血压' : '记录血氧',
+    icon: recordForm.type === 'heartRate' ? '💗' : recordForm.type === 'bloodPressure' ? '🩺' : '🫁',
+    size: 'small',
+    theme: 'default',
+    showFooter: false
+  }">
+  <div class="record-modal-content">
       <form (ngSubmit)="submitRecord()">
         <div class="form-group">
           <label for="recordValue">{{ recordForm.type === 'heartRate' ? '心率值' : recordForm.type === 'bloodPressure' ? '收缩压' : '血氧饱和度' }}:</label>
@@ -402,60 +404,119 @@
           <button type="submit" class="submit-btn">保存</button>
         </div>
       </form>
-    </div>
   </div>
-</div>
-}
+</app-modal>
 
 <!-- 详情模态框 -->
-@if (showDetailModal) {
-<div class="modal">
-  <div class="modal-content detail-modal">
-    <div class="modal-header">
-      <h3>
-        {{ selectedMetric === 'heartRate' ? '心率详情' : 
+<app-modal 
+  [(isOpen)]="showDetailModal"
+  [config]="{
+    title: selectedMetric === 'heartRate' ? '心率详情' : 
            selectedMetric === 'bloodPressure' ? '血压详情' : 
            selectedMetric === 'bloodOxygen' ? '血氧详情' : 
-           selectedMetric === 'menstrualCycle' ? '经期详情' : '健康综合报告' }}
-      </h3>
-      <button class="close-btn" (click)="closeDetailModal()">×</button>
-    </div>
-    <div class="modal-body">
+           selectedMetric === 'menstrualCycle' ? '经期详情' : '健康综合报告',
+    icon: '📊',
+    size: selectedMetric === 'comprehensive' ? 'large' : 'medium',
+    theme: 'gradient'
+  }">
+  <div class="detail-modal-content">
       @if (selectedMetric === 'comprehensive') {
-        <div class="report-content">
-          <h2>健康状况报告</h2>
-          
-          <h3>心率状况</h3>
-          <ul>
-            <li>当前值: {{ heartRate.value }} {{ heartRate.unit }}</li>
-            <li>状态: {{ getStatusText(heartRate.status) }}</li>
-            <li>趋势: {{ getTrendText(heartRate.trend) }}</li>
-          </ul>
-          
-          <h3>血压状况</h3>
-          <ul>
-            <li>当前值: {{ bloodPressure.systolic.value }}/{{ bloodPressure.diastolic.value }} {{ bloodPressure.systolic.unit }}</li>
-            <li>状态: {{ getStatusText(bloodPressure.systolic.status) }}</li>
-            <li>趋势: {{ getTrendText(bloodPressure.systolic.trend) }}</li>
-          </ul>
+        <div class="report-content-new">
+          <div class="report-header">
+            <h2>📋 健康状况报告</h2>
+            <p class="report-date">{{ today | date: 'yyyy年MM月dd日' }}</p>
+          </div>
           
-          <h3>血氧状况</h3>
-          <ul>
-            <li>当前值: {{ bloodOxygen.value }} {{ bloodOxygen.unit }}</li>
-            <li>状态: {{ getStatusText(bloodOxygen.status) }}</li>
-            <li>趋势: {{ getTrendText(bloodOxygen.trend) }}</li>
-          </ul>
+          <div class="health-metrics-grid">
+            <!-- 心率卡片 -->
+            <div class="metric-report-card">
+              <div class="metric-card-header">
+                <span class="metric-icon-large">💗</span>
+                <h3>心率状况</h3>
+              </div>
+              <div class="metric-card-body">
+                <div class="metric-main-value">
+                  <span class="value-number">{{ heartRate.value }}</span>
+                  <span class="value-unit">{{ heartRate.unit }}</span>
+                  <span class="status-badge badge-{{ heartRate.status }}">{{ getStatusText(heartRate.status) }}</span>
+                </div>
+                <div class="metric-trend">
+                  <span class="trend-label">趋势:</span>
+                  <span class="trend-value trend-{{ heartRate.trend }}">{{ getTrendIcon(heartRate.trend) }} {{ getTrendText(heartRate.trend) }}</span>
+                </div>
+              </div>
+            </div>
+            
+            <!-- 血压卡片 -->
+            <div class="metric-report-card">
+              <div class="metric-card-header">
+                <span class="metric-icon-large">🩺</span>
+                <h3>血压状况</h3>
+              </div>
+              <div class="metric-card-body">
+                <div class="metric-main-value">
+                  <span class="value-number">{{ bloodPressure.systolic.value }}/{{ bloodPressure.diastolic.value }}</span>
+                  <span class="value-unit">{{ bloodPressure.systolic.unit }}</span>
+                  <span class="status-badge badge-{{ bloodPressure.systolic.status }}">{{ getStatusText(bloodPressure.systolic.status) }}</span>
+                </div>
+                <div class="metric-trend">
+                  <span class="trend-label">趋势:</span>
+                  <span class="trend-value trend-{{ bloodPressure.systolic.trend }}">{{ getTrendIcon(bloodPressure.systolic.trend) }} {{ getTrendText(bloodPressure.systolic.trend) }}</span>
+                </div>
+              </div>
+            </div>
+            
+            <!-- 血氧卡片 -->
+            <div class="metric-report-card">
+              <div class="metric-card-header">
+                <span class="metric-icon-large">🫁</span>
+                <h3>血氧状况</h3>
+              </div>
+              <div class="metric-card-body">
+                <div class="metric-main-value">
+                  <span class="value-number">{{ bloodOxygen.value }}</span>
+                  <span class="value-unit">{{ bloodOxygen.unit }}</span>
+                  <span class="status-badge badge-{{ bloodOxygen.status }}">{{ getStatusText(bloodOxygen.status) }}</span>
+                </div>
+                <div class="metric-trend">
+                  <span class="trend-label">趋势:</span>
+                  <span class="trend-value trend-{{ bloodOxygen.trend }}">{{ getTrendIcon(bloodOxygen.trend) }} {{ getTrendText(bloodOxygen.trend) }}</span>
+                </div>
+              </div>
+            </div>
+          </div>
           
-          <h3>综合建议</h3>
-          <ul>
-            <li>保持每日30分钟中等强度有氧运动</li>
-            <li>饮食清淡,减少盐分和脂肪摄入</li>
-            <li>保证充足睡眠,避免熬夜</li>
-            <li>保持良好心态,避免长期精神压力</li>
-            @if (bloodPressure.systolic.status !== 'normal' || bloodPressure.diastolic.status !== 'normal') {
-              <li>建议每日测量血压并记录,如有异常及时就医</li>
-            }
-          </ul>
+          <!-- 综合建议 -->
+          <div class="advice-section-new">
+            <div class="advice-header">
+              <span class="advice-icon">💡</span>
+              <h3>综合建议</h3>
+            </div>
+            <div class="advice-list">
+              <div class="advice-item">
+                <span class="advice-item-icon">🏃</span>
+                <span class="advice-text">保持每日30分钟中等强度有氧运动</span>
+              </div>
+              <div class="advice-item">
+                <span class="advice-item-icon">🥗</span>
+                <span class="advice-text">饮食清淡,减少盐分和脂肪摄入</span>
+              </div>
+              <div class="advice-item">
+                <span class="advice-item-icon">😴</span>
+                <span class="advice-text">保证充足睡眠,避免熬夜</span>
+              </div>
+              <div class="advice-item">
+                <span class="advice-item-icon">😊</span>
+                <span class="advice-text">保持良好心态,避免长期精神压力</span>
+              </div>
+              @if (bloodPressure.systolic.status !== 'normal' || bloodPressure.diastolic.status !== 'normal') {
+                <div class="advice-item warning">
+                  <span class="advice-item-icon">⚠️</span>
+                  <span class="advice-text">建议每日测量血压并记录,如有异常及时就医</span>
+                </div>
+              }
+            </div>
+          </div>
         </div>
       } @else if (selectedMetric === 'heartRate') {
         <div class="metric-details">
@@ -556,7 +617,5 @@
           </div>
         </div>
       }
-    </div>
   </div>
-</div>
-}
+</app-modal>

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
campus_health_app/frontend/campus-health-app/src/app/modules/monitoring/monitoring.component.scss


+ 327 - 164
campus_health_app/frontend/campus-health-app/src/app/modules/monitoring/monitoring.component.ts

@@ -1,11 +1,11 @@
-import { Component, OnInit } from '@angular/core';
+import { Component, OnInit, OnDestroy } from '@angular/core';
 import { CommonModule } from '@angular/common';
 import { Router, RouterModule } from '@angular/router';
 import { FormsModule } from '@angular/forms';
-import { Chart, ChartConfiguration, registerables } from 'chart.js';
+import { ModalComponent } from '../../shared/components/modal/modal.component';
 
-// 注册Chart.js所需组件
-Chart.register(...registerables);
+// 声明 ECharts 全局类型
+declare const echarts: any;
 
 // 健康指标数据接口
 export interface HealthMetric {
@@ -68,11 +68,11 @@ export interface ChronicCondition {
 @Component({
   selector: 'app-monitoring',
   standalone: true,
-  imports: [CommonModule, RouterModule, FormsModule],
+  imports: [CommonModule, RouterModule, FormsModule, ModalComponent],
   templateUrl: './monitoring.component.html',
   styleUrl: './monitoring.component.scss'
 })
-export class MonitoringComponent implements OnInit {
+export class MonitoringComponent implements OnInit, OnDestroy {
   today: Date = new Date();
   
   // 心率数据
@@ -143,11 +143,11 @@ export class MonitoringComponent implements OnInit {
   // 经期阶段数据
   cyclePhases: MenstrualPhase[] = [];
 
-  // 图表实例
-  heartRateChart: Chart | null = null;
-  bloodPressureChart: Chart | null = null;
-  bloodOxygenChart: Chart | null = null;
-  correlationChart: Chart | null = null;
+  // ECharts 图表实例
+  heartRateChart: any = null;
+  bloodPressureChart: any = null;
+  bloodOxygenChart: any = null;
+  correlationChart: any = null;
 
   // 历史数据
   heartRateHistory: HealthMetricHistory[] = [];
@@ -184,6 +184,26 @@ export class MonitoringComponent implements OnInit {
     setTimeout(() => {
       this.initializeCharts();
     }, 100);
+    
+    // 添加窗口调整大小事件监听器,使图表自适应
+    window.addEventListener('resize', this.handleResize.bind(this));
+  }
+  
+  ngOnDestroy(): void {
+    // 移除事件监听器并销毁图表
+    window.removeEventListener('resize', this.handleResize.bind(this));
+    if (this.heartRateChart) this.heartRateChart.dispose();
+    if (this.bloodPressureChart) this.bloodPressureChart.dispose();
+    if (this.bloodOxygenChart) this.bloodOxygenChart.dispose();
+    if (this.correlationChart) this.correlationChart.dispose();
+  }
+  
+  // 处理窗口调整大小
+  handleResize(): void {
+    if (this.heartRateChart) this.heartRateChart.resize();
+    if (this.bloodPressureChart) this.bloodPressureChart.resize();
+    if (this.bloodOxygenChart) this.bloodOxygenChart.resize();
+    if (this.correlationChart) this.correlationChart.resize();
   }
 
   // 生成模拟历史数据
@@ -270,222 +290,365 @@ export class MonitoringComponent implements OnInit {
     };
   }
 
-  // 初始化心率图表
+  // 初始化心率图表 (使用 ECharts)
   initializeHeartRateChart(data: HealthMetricHistory[]): void {
-    const ctx = document.getElementById('heartRateChart') as HTMLCanvasElement;
-    if (!ctx) return;
+    const chartDom = document.getElementById('heartRateChart');
+    if (!chartDom) return;
 
     // 销毁现有图表
     if (this.heartRateChart) {
-      this.heartRateChart.destroy();
+      this.heartRateChart.dispose();
     }
 
-    const chartConfig: ChartConfiguration = {
-      type: 'line',
-      data: {
-        labels: data.map(item => item.date.toLocaleDateString('zh-CN')),
-        datasets: [{
-          label: '心率 (bpm)',
-          data: data.map(item => item.value),
-          borderColor: '#ef4444',
-          backgroundColor: 'rgba(239, 68, 68, 0.1)',
-          tension: 0.3,
-          fill: true
-        }]
+    // 初始化 ECharts 实例
+    this.heartRateChart = echarts.init(chartDom);
+
+    const option = {
+      tooltip: {
+        trigger: 'axis',
+        axisPointer: {
+          type: 'cross'
+        }
       },
-      options: {
-        responsive: true,
-        maintainAspectRatio: false,
-        plugins: {
-          legend: {
-            display: false
-          },
-          tooltip: {
-            mode: 'index',
-            intersect: false
-          }
+      grid: {
+        left: '8%',
+        right: '4%',
+        bottom: '15%',
+        top: '10%',
+        containLabel: false
+      },
+      xAxis: {
+        type: 'category',
+        boundaryGap: false,
+        data: data.map(item => item.date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })),
+        axisLabel: {
+          rotate: 45,
+          fontSize: 10,
+          margin: 10
+        }
+      },
+      yAxis: {
+        type: 'value',
+        name: 'bpm',
+        nameTextStyle: {
+          fontSize: 12,
+          padding: [0, 0, 0, 10]
         },
-        scales: {
-          y: {
-            beginAtZero: false,
-            min: Math.min(...data.map(d => d.value)) - 10,
-            max: Math.max(...data.map(d => d.value)) + 10
+        min: Math.max(50, Math.min(...data.map(d => d.value)) - 10),
+        max: Math.min(120, Math.max(...data.map(d => d.value)) + 10)
+      },
+      series: [{
+        name: '心率',
+        type: 'line',
+        smooth: true,
+        data: data.map(item => item.value),
+        areaStyle: {
+          color: {
+            type: 'linear',
+            x: 0,
+            y: 0,
+            x2: 0,
+            y2: 1,
+            colorStops: [{
+              offset: 0,
+              color: 'rgba(239, 68, 68, 0.3)'
+            }, {
+              offset: 1,
+              color: 'rgba(239, 68, 68, 0.05)'
+            }]
           }
+        },
+        lineStyle: {
+          color: '#ef4444',
+          width: 2
+        },
+        itemStyle: {
+          color: '#ef4444'
         }
-      }
+      }]
     };
 
-    this.heartRateChart = new Chart(ctx, chartConfig);
+    this.heartRateChart.setOption(option);
   }
 
-  // 初始化血压图表
+  // 初始化血压图表 (使用 ECharts)
   initializeBloodPressureChart(data: { systolic: HealthMetricHistory[]; diastolic: HealthMetricHistory[] }): void {
-    const ctx = document.getElementById('bloodPressureChart') as HTMLCanvasElement;
-    if (!ctx) return;
+    const chartDom = document.getElementById('bloodPressureChart');
+    if (!chartDom) return;
 
     // 销毁现有图表
     if (this.bloodPressureChart) {
-      this.bloodPressureChart.destroy();
+      this.bloodPressureChart.dispose();
     }
 
-    const chartConfig: ChartConfiguration = {
-      type: 'line',
-      data: {
-        labels: data.systolic.map(item => item.date.toLocaleDateString('zh-CN')),
-        datasets: [
-          {
-            label: '收缩压 (mmHg)',
-            data: data.systolic.map(item => item.value),
-            borderColor: '#3b82f6',
-            backgroundColor: 'rgba(59, 130, 246, 0.1)',
-            tension: 0.3
-          },
-          {
-            label: '舒张压 (mmHg)',
-            data: data.diastolic.map(item => item.value),
-            borderColor: '#10b981',
-            backgroundColor: 'rgba(16, 185, 129, 0.1)',
-            tension: 0.3
-          }
-        ]
+    // 初始化 ECharts 实例
+    this.bloodPressureChart = echarts.init(chartDom);
+
+    const option = {
+      tooltip: {
+        trigger: 'axis',
+        axisPointer: {
+          type: 'cross'
+        }
+      },
+      legend: {
+        data: ['收缩压', '舒张压'],
+        top: 0,
+        textStyle: {
+          fontSize: 12
+        }
       },
-      options: {
-        responsive: true,
-        maintainAspectRatio: false,
-        plugins: {
-          tooltip: {
-            mode: 'index',
-            intersect: false
+      grid: {
+        left: '8%',
+        right: '4%',
+        bottom: '15%',
+        top: '15%',
+        containLabel: false
+      },
+      xAxis: {
+        type: 'category',
+        boundaryGap: false,
+        data: data.systolic.map(item => item.date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })),
+        axisLabel: {
+          rotate: 45,
+          fontSize: 10,
+          margin: 10
+        }
+      },
+      yAxis: {
+        type: 'value',
+        name: 'mmHg',
+        nameTextStyle: {
+          fontSize: 12,
+          padding: [0, 0, 0, 10]
+        }
+      },
+      series: [
+        {
+          name: '收缩压',
+          type: 'line',
+          smooth: true,
+          data: data.systolic.map(item => item.value),
+          lineStyle: {
+            color: '#3b82f6',
+            width: 2
+          },
+          itemStyle: {
+            color: '#3b82f6'
+          },
+          areaStyle: {
+            color: {
+              type: 'linear',
+              x: 0,
+              y: 0,
+              x2: 0,
+              y2: 1,
+              colorStops: [{
+                offset: 0,
+                color: 'rgba(59, 130, 246, 0.2)'
+              }, {
+                offset: 1,
+                color: 'rgba(59, 130, 246, 0.05)'
+              }]
+            }
           }
         },
-        scales: {
-          y: {
-            beginAtZero: false
+        {
+          name: '舒张压',
+          type: 'line',
+          smooth: true,
+          data: data.diastolic.map(item => item.value),
+          lineStyle: {
+            color: '#10b981',
+            width: 2
+          },
+          itemStyle: {
+            color: '#10b981'
+          },
+          areaStyle: {
+            color: {
+              type: 'linear',
+              x: 0,
+              y: 0,
+              x2: 0,
+              y2: 1,
+              colorStops: [{
+                offset: 0,
+                color: 'rgba(16, 185, 129, 0.2)'
+              }, {
+                offset: 1,
+                color: 'rgba(16, 185, 129, 0.05)'
+              }]
+            }
           }
         }
-      }
+      ]
     };
 
-    this.bloodPressureChart = new Chart(ctx, chartConfig);
+    this.bloodPressureChart.setOption(option);
   }
 
-  // 初始化血氧图表
+  // 初始化血氧图表 (使用 ECharts)
   initializeBloodOxygenChart(data: HealthMetricHistory[]): void {
-    const ctx = document.getElementById('bloodOxygenChart') as HTMLCanvasElement;
-    if (!ctx) return;
+    const chartDom = document.getElementById('bloodOxygenChart');
+    if (!chartDom) return;
 
     // 销毁现有图表
     if (this.bloodOxygenChart) {
-      this.bloodOxygenChart.destroy();
+      this.bloodOxygenChart.dispose();
     }
 
-    const chartConfig: ChartConfiguration = {
-      type: 'line',
-      data: {
-        labels: data.map(item => item.date.toLocaleDateString('zh-CN')),
-        datasets: [{
-          label: '血氧饱和度 (%)',
-          data: data.map(item => item.value),
-          borderColor: '#8b5cf6',
-          backgroundColor: 'rgba(139, 92, 246, 0.1)',
-          tension: 0.3,
-          fill: true
-        }]
+    // 初始化 ECharts 实例
+    this.bloodOxygenChart = echarts.init(chartDom);
+
+    const option = {
+      tooltip: {
+        trigger: 'axis',
+        axisPointer: {
+          type: 'cross'
+        }
       },
-      options: {
-        responsive: true,
-        maintainAspectRatio: false,
-        plugins: {
-          legend: {
-            display: false
-          },
-          tooltip: {
-            mode: 'index',
-            intersect: false
-          }
+      grid: {
+        left: '8%',
+        right: '4%',
+        bottom: '15%',
+        top: '10%',
+        containLabel: false
+      },
+      xAxis: {
+        type: 'category',
+        boundaryGap: false,
+        data: data.map(item => item.date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })),
+        axisLabel: {
+          rotate: 45,
+          fontSize: 10,
+          margin: 10
+        }
+      },
+      yAxis: {
+        type: 'value',
+        name: '%',
+        nameTextStyle: {
+          fontSize: 12,
+          padding: [0, 0, 0, 10]
         },
-        scales: {
-          y: {
-            beginAtZero: false,
-            min: 90,
-            max: 100
+        min: 90,
+        max: 100
+      },
+      series: [{
+        name: '血氧饱和度',
+        type: 'line',
+        smooth: true,
+        data: data.map(item => item.value),
+        areaStyle: {
+          color: {
+            type: 'linear',
+            x: 0,
+            y: 0,
+            x2: 0,
+            y2: 1,
+            colorStops: [{
+              offset: 0,
+              color: 'rgba(139, 92, 246, 0.3)'
+            }, {
+              offset: 1,
+              color: 'rgba(139, 92, 246, 0.05)'
+            }]
           }
+        },
+        lineStyle: {
+          color: '#8b5cf6',
+          width: 2
+        },
+        itemStyle: {
+          color: '#8b5cf6'
         }
-      }
+      }]
     };
 
-    this.bloodOxygenChart = new Chart(ctx, chartConfig);
+    this.bloodOxygenChart.setOption(option);
   }
 
-  // 初始化心率-血压关联图表
+  // 初始化心率-血压关联图表 (使用 ECharts)
   initializeCorrelationChart(heartRateData: HealthMetricHistory[], bloodPressureData: HealthMetricHistory[]): void {
-    const ctx = document.getElementById('correlationChart') as HTMLCanvasElement;
-    if (!ctx) return;
+    const chartDom = document.getElementById('correlationChart');
+    if (!chartDom) return;
 
     // 销毁现有图表
     if (this.correlationChart) {
-      this.correlationChart.destroy();
+      this.correlationChart.dispose();
     }
 
+    // 初始化 ECharts 实例
+    this.correlationChart = echarts.init(chartDom);
+
     // 确保数据点数量一致
     const dataLength = Math.min(heartRateData.length, bloodPressureData.length);
-    const scatterData: { x: number; y: number }[] = [];
-    const labels: string[] = [];
+    const scatterData: any[] = [];
     
     for (let i = 0; i < dataLength; i++) {
       scatterData.push({
-        x: heartRateData[i].value,
-        y: bloodPressureData[i].value
+        value: [heartRateData[i].value, bloodPressureData[i].value],
+        date: heartRateData[i].date.toLocaleDateString('zh-CN')
       });
-      labels.push(heartRateData[i].date.toLocaleDateString('zh-CN'));
     }
 
-    const chartConfig: ChartConfiguration = {
-      type: 'scatter',
-      data: {
-        datasets: [{
-          label: '心率 vs 收缩压',
-          data: scatterData,
-          backgroundColor: 'rgba(239, 68, 68, 0.7)',
-          pointRadius: 6,
-          pointHoverRadius: 8
-        }]
+    const option = {
+      tooltip: {
+        trigger: 'item',
+        formatter: (params: any) => {
+          return `心率: ${params.value[0]} bpm<br/>收缩压: ${params.value[1]} mmHg<br/>日期: ${params.data.date}`;
+        }
       },
-      options: {
-        responsive: true,
-        maintainAspectRatio: false,
-        plugins: {
-          tooltip: {
-            callbacks: {
-              label: function(context) {
-                const index = context.dataIndex;
-                return [
-                  `心率: ${context.parsed.x} bpm`,
-                  `收缩压: ${context.parsed.y} mmHg`,
-                  `日期: ${labels[index]}`
-                ];
-              }
-            }
-          }
+      grid: {
+        left: '10%',
+        right: '4%',
+        bottom: '15%',
+        top: '5%',
+        containLabel: false
+      },
+      xAxis: {
+        type: 'value',
+        name: '心率 (bpm)',
+        nameLocation: 'center',
+        nameGap: 35,
+        nameTextStyle: {
+          fontSize: 13,
+          fontWeight: 500
+        }
+      },
+      yAxis: {
+        type: 'value',
+        name: '收缩压 (mmHg)',
+        nameLocation: 'center',
+        nameGap: 50,
+        nameTextStyle: {
+          fontSize: 13,
+          fontWeight: 500
+        }
+      },
+      series: [{
+        name: '心率与收缩压',
+        type: 'scatter',
+        symbolSize: 8,
+        data: scatterData,
+        itemStyle: {
+          color: '#ef4444',
+          opacity: 0.7
         },
-        scales: {
-          x: {
-            title: {
-              display: true,
-              text: '心率 (bpm)'
-            }
-          },
-          y: {
-            title: {
-              display: true,
-              text: '收缩压 (mmHg)'
-            }
+        emphasis: {
+          itemStyle: {
+            color: '#dc2626',
+            borderColor: '#fff',
+            borderWidth: 2,
+            shadowBlur: 10,
+            shadowColor: 'rgba(239, 68, 68, 0.5)'
           }
         }
-      }
+      }]
     };
 
-    this.correlationChart = new Chart(ctx, chartConfig);
+    this.correlationChart.setOption(option);
   }
 
   // 时间范围改变

+ 38 - 17
campus_health_app/frontend/campus-health-app/src/app/modules/weight/weight.component.html

@@ -29,7 +29,7 @@
 
   <div class="chart-section">
     <div class="trend-chart-container">
-      <div echarts [options]="trendChartOption" class="chart"></div>
+      <div id="trendChart" class="chart" style="width: 100%; height: 100%;"></div>
       <div class="trend-summary" *ngIf="records.length > 0">
         <p>
           当前 {{ records[0].weight.toFixed(1) }}kg,较 {{ records[records.length - 1].date }}
@@ -40,7 +40,7 @@
     </div>
 
     <div class="progress-chart-container" *ngIf="goal">
-      <div echarts [options]="progressChartOption" class="chart-small"></div>
+      <div id="progressChart" class="chart-small" style="width: 100%; height: 100%;"></div>
     </div>
   </div>
 
@@ -82,15 +82,22 @@
     </div>
   </div>
 
-  <div class="change-chart-section">
-    <div class="chart-header">
-      <button class="toggle-btn" (click)="toggleChartPeriod()">切换至{{ chartPeriod === 'weekly' ? '月' : '周' }}视图</button>
+  <!-- 体重变化与体脂率分析(横向布局) -->
+  <div class="analysis-charts-container">
+    <div class="change-chart-section">
+      <div class="chart-header">
+        <h3 class="chart-title">体重变化趋势</h3>
+        <button class="toggle-btn" (click)="toggleChartPeriod()">切换至{{ chartPeriod === 'weekly' ? '月' : '周' }}视图</button>
+      </div>
+      <div id="changeChart" class="chart-medium" style="width: 100%; height: 100%;"></div>
     </div>
-    <div echarts [options]="changeChartOption" class="chart-medium"></div>
-  </div>
 
-  <div class="scatter-chart-section">
-    <div echarts [options]="scatterChartOption" class="chart-medium"></div>
+    <div class="scatter-chart-section">
+      <div class="chart-header">
+        <h3 class="chart-title">体重体脂率关系</h3>
+      </div>
+      <div id="scatterChart" class="chart-medium" style="width: 100%; height: 100%;"></div>
+    </div>
   </div>
 
   <div class="anomaly-section" *ngIf="anomalies.length > 0">
@@ -104,9 +111,16 @@
   </div>
 
   <!-- 添加记录对话框 -->
-  <div class="dialog-overlay" *ngIf="showAddRecordDialog" (click)="showAddRecordDialog = false">
-    <div class="dialog" (click)="$event.stopPropagation()">
-      <h2>录入体重数据</h2>
+  <app-modal 
+    [(isOpen)]="showAddRecordDialog"
+    [config]="{
+      title: '录入体重数据',
+      icon: '⚖️',
+      size: 'medium',
+      theme: 'default',
+      showFooter: false
+    }">
+    <div class="weight-record-form">
       <div class="form-group">
         <label>日期</label>
         <input type="date" [(ngModel)]="newRecord.date" max="{{ today }}" />
@@ -163,12 +177,19 @@
         <button class="btn btn-primary" (click)="addRecord()">保存</button>
       </div>
     </div>
-  </div>
+  </app-modal>
 
   <!-- 目标设置对话框 -->
-  <div class="dialog-overlay" *ngIf="showGoalDialog" (click)="showGoalDialog = false">
-    <div class="dialog" (click)="$event.stopPropagation()">
-      <h2>设置目标</h2>
+  <app-modal 
+    [(isOpen)]="showGoalDialog"
+    [config]="{
+      title: '设置目标',
+      icon: '🎯',
+      size: 'small',
+      theme: 'gradient',
+      showFooter: false
+    }">
+    <div class="goal-setting-form">
       <div class="form-group">
         <label>目标体重 (kg)</label>
         <input type="number" [(ngModel)]="newGoal.targetWeight" step="0.1" min="30" max="200" />
@@ -186,7 +207,7 @@
         <button class="btn btn-primary" (click)="setGoal()">保存</button>
       </div>
     </div>
-  </div>
+  </app-modal>
 
   <!-- 调试:一键生成90天模拟数据 -->
   <div style="padding: 16px; text-align: center;">

+ 509 - 89
campus_health_app/frontend/campus-health-app/src/app/modules/weight/weight.component.scss

@@ -1,85 +1,128 @@
 .weight-container {
   min-height: 100vh;
-  background: #f5f5f5;
-  padding-bottom: 20px;
+  background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
+  padding-bottom: 30px;
 }
 
 .header {
-  background: #fff;
-  padding: 16px;
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  padding: 20px 24px;
   display: flex;
   align-items: center;
   justify-content: space-between;
-  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
+  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
   position: sticky;
   top: 0;
   z-index: 10;
 
   h1 {
-    font-size: 20px;
-    font-weight: bold;
+    font-size: 22px;
+    font-weight: 700;
     margin: 0;
+    color: #fff;
   }
 
   .back-btn {
-    background: none;
-    border: none;
-    font-size: 16px;
+    background: rgba(255, 255, 255, 0.1);
+    border: 1px solid rgba(255, 255, 255, 0.2);
+    border-radius: 12px;
+    padding: 10px 16px;
+    font-size: 15px;
     cursor: pointer;
-    color: #3b82f6;
+    color: #fff;
+    transition: all 0.3s ease;
+    backdrop-filter: blur(10px);
+    
+    &:hover {
+      background: rgba(255, 255, 255, 0.2);
+      transform: translateY(-2px);
+    }
   }
 
   .header-actions {
     display: flex;
-    gap: 8px;
+    gap: 12px;
   }
 }
 
 .btn {
-  padding: 8px 16px;
+  padding: 10px 20px;
   border: none;
-  border-radius: 8px;
+  border-radius: 10px;
   font-size: 14px;
+  font-weight: 600;
   cursor: pointer;
-  transition: all 0.2s ease;
+  transition: all 0.3s ease;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
 
   &.btn-primary {
-    background: #3b82f6;
+    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
     color: #fff;
-    &:hover { background: #2563eb; }
+    
+    &:hover {
+      transform: translateY(-2px);
+      box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
+    }
   }
+  
   &.btn-secondary {
-    background: #e5e7eb;
+    background: #fff;
     color: #374151;
-    &:hover { background: #d1d5db; }
+    border: 2px solid #e5e7eb;
+    
+    &:hover {
+      background: #f9fafb;
+      border-color: #d1d5db;
+      transform: translateY(-2px);
+    }
   }
 }
 
 .stats-cards {
   display: grid;
   grid-template-columns: repeat(3, 1fr);
-  gap: 12px;
-  padding: 16px;
+  gap: 16px;
+  padding: 20px 24px;
+  margin-top: 0;
 }
 
 .stat-card {
-  background: #fff;
-  padding: 16px;
-  border-radius: 10px;
+  background: linear-gradient(135deg, #fff 0%, #fafbff 100%);
+  padding: 20px;
+  border-radius: 12px;
   text-align: center;
-  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+  transition: all 0.3s ease;
+  border: 1px solid #e5e7eb;
+
+  &:hover {
+    transform: translateY(-4px);
+    box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
+  }
 
   .stat-label {
-    font-size: 12px;
+    font-size: 13px;
     color: #6b7280;
-    margin-bottom: 8px;
+    margin-bottom: 10px;
+    font-weight: 600;
+    text-transform: uppercase;
+    letter-spacing: 0.5px;
   }
+  
   .stat-value {
-    font-size: 24px;
-    font-weight: bold;
+    font-size: 28px;
+    font-weight: 800;
     color: #111827;
-    span { font-size: 14px; color: #6b7280; font-weight: 500; }
+    line-height: 1.2;
+    
+    span {
+      font-size: 14px;
+      color: #9ca3af;
+      font-weight: 500;
+      margin-left: 4px;
+    }
   }
+  
   .positive { color: #ef4444; }
   .negative { color: #10b981; }
 }
@@ -87,65 +130,240 @@
 .chart-section {
   display: grid;
   grid-template-columns: 2fr 1fr;
-  gap: 16px;
-  padding: 0 16px;
-  margin-top: 8px;
+  gap: 20px;
+  padding: 0 24px;
+  margin-top: 0;
+  margin-bottom: 20px;
 }
 
 .trend-chart-container {
   background: #fff;
-  border-radius: 10px;
-  padding: 16px;
-  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
-  .chart { width: 100%; height: 400px; }
+  border-radius: 12px;
+  padding: 20px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+  border: 1px solid #e5e7eb;
+  min-height: 450px;
+  display: flex;
+  flex-direction: column;
+  
+  .chart {
+    width: 100%;
+    height: 380px;
+    flex-shrink: 0;
+  }
+  
   .trend-summary {
-    margin-top: 12px; padding-top: 12px; border-top: 1px solid #e5e7eb;
-    text-align: center; color: #6b7280; font-size: 14px;
+    margin-top: 16px;
+    padding-top: 16px;
+    border-top: 2px solid #f0f0f0;
+    text-align: center;
+    color: #6b7280;
+    font-size: 14px;
+    line-height: 1.6;
+    flex-shrink: 0;
+    
+    p {
+      margin: 0;
+    }
   }
 }
 
 .progress-chart-container {
   background: #fff;
-  border-radius: 10px;
-  padding: 16px;
-  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
-  .chart-small { width: 100%; height: 300px; }
+  border-radius: 12px;
+  padding: 20px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+  border: 1px solid #e5e7eb;
+  min-height: 450px;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  
+  .chart-small {
+    width: 100%;
+    height: 350px;
+  }
 }
 
 .filter-bar {
   display: flex;
-  gap: 8px;
-  padding: 16px;
+  gap: 10px;
+  padding: 18px 20px;
   background: #fff;
-  margin: 16px;
-  border-radius: 10px;
-  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
+  margin: 0 24px 20px;
+  border-radius: 12px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+  border: 1px solid #e5e7eb;
+  
   .filter-btn {
-    flex: 1; padding: 10px; border: 1px solid #e5e7eb; background: #fff; border-radius: 8px; cursor: pointer;
-    &.active { background: #3b82f6; color: #fff; border-color: #3b82f6; }
-    &:hover:not(.active) { background: #f3f4f6; }
+    flex: 1;
+    padding: 12px;
+    border: 2px solid #e5e7eb;
+    background: #fff;
+    border-radius: 10px;
+    cursor: pointer;
+    font-size: 14px;
+    font-weight: 600;
+    transition: all 0.3s ease;
+    
+    &.active {
+      background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+      color: #fff;
+      border-color: transparent;
+      transform: translateY(-2px);
+      box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
+    }
+    
+    &:hover:not(.active) {
+      background: #f9fafb;
+      border-color: #d1d5db;
+      transform: translateY(-1px);
+    }
   }
+  
   .filter-select {
-    padding: 10px; border: 1px solid #e5e7eb; border-radius: 8px; background: #fff; min-width: 120px;
+    padding: 12px;
+    border: 2px solid #e5e7eb;
+    border-radius: 10px;
+    background: #fff;
+    min-width: 140px;
+    font-size: 14px;
+    font-weight: 600;
+    cursor: pointer;
+    transition: all 0.3s ease;
+    
+    &:hover {
+      border-color: #d1d5db;
+    }
+    
+    &:focus {
+      outline: none;
+      border-color: #667eea;
+      box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
+    }
   }
 }
 
+// 分析图表横向布局容器
+.analysis-charts-container {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 20px;
+  margin: 0 24px 20px;
+}
+
 .change-chart-section, .scatter-chart-section {
-  background: #fff; border-radius: 10px; padding: 16px; margin: 0 16px 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.08);
-  .chart-header { display: flex; justify-content: flex-end; margin-bottom: 12px; }
-  .toggle-btn { background: #e5e7eb; border: none; padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 12px; }
-  .chart-medium { width: 100%; height: 300px; }
+  background: #fff;
+  border-radius: 12px;
+  padding: 20px;
+  margin: 0; // 移除外边距,由容器gap控制
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+  border: 1px solid #e5e7eb;
+  min-height: 400px;
+  display: flex;
+  flex-direction: column;
+  
+  .chart-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    margin-bottom: 16px;
+    
+    .chart-title {
+      font-size: 16px;
+      font-weight: 700;
+      color: #1f2937;
+      margin: 0;
+      background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+      -webkit-background-clip: text;
+      -webkit-text-fill-color: transparent;
+      background-clip: text;
+    }
+  }
+  
+  .toggle-btn {
+    background: linear-gradient(135deg, #f3f4f6 0%, #e5e7eb 100%);
+    border: 1px solid #d1d5db;
+    padding: 8px 16px;
+    border-radius: 8px;
+    cursor: pointer;
+    font-size: 13px;
+    font-weight: 600;
+    transition: all 0.3s ease;
+    
+    &:hover {
+      background: linear-gradient(135deg, #e5e7eb 0%, #d1d5db 100%);
+      transform: translateY(-2px);
+      box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+    }
+  }
+  
+  .chart-medium {
+    width: 100%;
+    flex: 1;
+    min-height: 320px;
+  }
 }
 
 .anomaly-section {
-  background: #fff; border-radius: 10px; padding: 16px; margin: 0 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.08);
-  h3 { font-size: 16px; font-weight: bold; margin: 0 0 12px 0; }
-  .anomaly-list { display: flex; flex-direction: column; gap: 8px; }
-  .anomaly-item { display: flex; align-items: center; gap: 8px; padding: 12px; border-radius: 8px; font-size: 14px; }
-  .anomaly-item.info { background: #dbeafe; color: #1e40af; }
-  .anomaly-item.warning { background: #fef3c7; color: #92400e; }
-  .anomaly-item.danger { background: #fee2e2; color: #991b1b; }
-  .anomaly-icon { font-size: 18px; }
+  background: #fff;
+  border-radius: 12px;
+  padding: 20px;
+  margin: 0 24px 20px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+  border: 1px solid #e5e7eb;
+  
+  h3 {
+    font-size: 17px;
+    font-weight: 700;
+    margin: 0 0 16px 0;
+    color: #1f2937;
+  }
+  
+  .anomaly-list {
+    display: flex;
+    flex-direction: column;
+    gap: 12px;
+  }
+  
+  .anomaly-item {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    padding: 14px 16px;
+    border-radius: 10px;
+    font-size: 14px;
+    line-height: 1.6;
+    transition: all 0.3s ease;
+    
+    &:hover {
+      transform: translateX(4px);
+      box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+    }
+  }
+  
+  .anomaly-item.info {
+    background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%);
+    color: #1e40af;
+    border-left: 4px solid #3b82f6;
+  }
+  
+  .anomaly-item.warning {
+    background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
+    color: #92400e;
+    border-left: 4px solid #f59e0b;
+  }
+  
+  .anomaly-item.danger {
+    background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%);
+    color: #991b1b;
+    border-left: 4px solid #ef4444;
+  }
+  
+  .anomaly-icon {
+    font-size: 20px;
+    flex-shrink: 0;
+  }
 }
 
 .dialog-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 50; padding: 20px; }
@@ -174,38 +392,240 @@
 }
 
 // 自定义日期选择器
-.custom-date-picker { background: #fff; padding: 16px; margin: 0 16px 16px; border-radius: 10px; box-shadow: 0 1px 3px rgba(0,0,0,0.08);
-  .date-range-inputs { display: flex; gap: 12px; align-items: end;
-    .date-input-group { flex: 1;
-      label { display: block; font-size: 13px; font-weight: 500; margin-bottom: 6px; color: #374151; }
-      input { width: 100%; padding: 8px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 14px; }
+.custom-date-picker {
+  background: #fff;
+  padding: 20px;
+  margin: 0 24px 20px;
+  border-radius: 12px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+  border: 1px solid #e5e7eb;
+  
+  .date-range-inputs {
+    display: flex;
+    gap: 16px;
+    align-items: flex-end;
+    
+    .date-input-group {
+      flex: 1;
+      
+      label {
+        display: block;
+        font-size: 13px;
+        font-weight: 600;
+        margin-bottom: 8px;
+        color: #374151;
+        text-transform: uppercase;
+        letter-spacing: 0.5px;
+      }
+      
+      input {
+        width: 100%;
+        padding: 12px;
+        border: 2px solid #e5e7eb;
+        border-radius: 10px;
+        font-size: 14px;
+        transition: all 0.3s ease;
+        
+        &:hover {
+          border-color: #d1d5db;
+        }
+        
+        &:focus {
+          outline: none;
+          border-color: #667eea;
+          box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
+        }
+      }
     }
   }
 }
 
 // 标签筛选
-.tag-filter { background: #fff; padding: 16px; margin: 0 16px 16px; border-radius: 10px; box-shadow: 0 1px 3px rgba(0,0,0,0.08);
-  .tag-filter-title { font-size: 14px; font-weight: 500; margin-bottom: 12px; color: #374151; }
-  .tag-filter-buttons { display: flex; flex-wrap: wrap; gap: 8px; }
-  .tag-filter-btn { padding: 6px 12px; border: 1px solid #e5e7eb; background: #fff; border-radius: 6px; cursor: pointer; font-size: 13px; transition: all 0.2s;
-    &:hover { background: #f3f4f6; }
-    &.active { background: #3b82f6; color: #fff; border-color: #3b82f6; }
+.tag-filter {
+  background: #fff;
+  padding: 20px;
+  margin: 0 24px 20px;
+  border-radius: 12px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+  border: 1px solid #e5e7eb;
+  
+  .tag-filter-title {
+    font-size: 14px;
+    font-weight: 700;
+    margin-bottom: 14px;
+    color: #1f2937;
+    text-transform: uppercase;
+    letter-spacing: 0.5px;
+  }
+  
+  .tag-filter-buttons {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 10px;
+  }
+  
+  .tag-filter-btn {
+    padding: 8px 16px;
+    border: 2px solid #e5e7eb;
+    background: #fff;
+    border-radius: 8px;
+    cursor: pointer;
+    font-size: 13px;
+    font-weight: 600;
+    transition: all 0.3s ease;
+    
+    &:hover {
+      background: #f9fafb;
+      border-color: #d1d5db;
+      transform: translateY(-1px);
+    }
+    
+    &.active {
+      background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+      color: #fff;
+      border-color: transparent;
+      transform: translateY(-2px);
+      box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
+    }
+  }
+}
+
+@media (max-width: 1024px) {
+  .chart-section {
+    grid-template-columns: 1fr;
+    gap: 20px;
+  }
+  
+  .progress-chart-container {
+    min-height: 400px;
+    
+    .chart-small {
+      height: 300px;
+    }
+  }
+  
+  .analysis-charts-container {
+    grid-template-columns: 1fr;
+    gap: 16px;
+  }
+  
+  .change-chart-section, .scatter-chart-section {
+    min-height: 350px;
+    
+    .chart-medium {
+      min-height: 280px;
+    }
   }
 }
 
 @media (max-width: 768px) {
-  .stats-cards { grid-template-columns: 1fr; }
-  .chart-section { grid-template-columns: 1fr; }
-  .progress-chart-container { order: -1; }
-  .header { flex-wrap: wrap; }
-  .header h1 { flex: 1 0 100%; text-align: center; margin: 8px 0; }
-  .header .header-actions { flex: 1; justify-content: center; }
-  .filter-bar { flex-wrap: wrap; }
-  .filter-bar .filter-btn { flex: 1 1 calc(50% - 4px); }
-  .filter-bar .filter-select { flex: 1 0 100%; margin-top: 8px; }
-  .custom-date-picker .date-range-inputs { flex-direction: column; align-items: stretch;
-    button { margin-top: 8px; }
-  }
-  .tag-filter .tag-filter-buttons { gap: 6px; }
-  .tag-filter .tag-filter-btn { flex: 1 1 auto; min-width: fit-content; }
+  .stats-cards {
+    grid-template-columns: 1fr;
+    gap: 12px;
+    padding: 16px;
+  }
+  
+  .stat-card {
+    padding: 16px;
+  }
+  
+  .chart-section {
+    grid-template-columns: 1fr;
+    padding: 0 16px;
+  }
+  
+  .progress-chart-container {
+    order: -1;
+  }
+  
+  .trend-chart-container {
+    .chart {
+      height: 300px;
+    }
+  }
+  
+  .header {
+    flex-wrap: wrap;
+    padding: 16px;
+    
+    h1 {
+      flex: 1 0 100%;
+      text-align: center;
+      margin: 8px 0;
+      font-size: 20px;
+    }
+    
+    .back-btn {
+      order: -1;
+      flex: 0 0 auto;
+    }
+    
+    .header-actions {
+      flex: 1;
+      justify-content: center;
+      width: 100%;
+      margin-top: 12px;
+    }
+  }
+  
+  .filter-bar {
+    flex-wrap: wrap;
+    padding: 14px 16px;
+    margin: 0 16px 16px;
+    
+    .filter-btn {
+      flex: 1 1 calc(50% - 5px);
+      padding: 10px;
+      font-size: 13px;
+    }
+    
+    .filter-select {
+      flex: 1 0 100%;
+      margin-top: 8px;
+    }
+  }
+  
+  .change-chart-section, .scatter-chart-section {
+    margin: 0 16px 16px;
+    padding: 16px;
+    
+    .chart-medium {
+      height: 250px;
+    }
+  }
+  
+  .anomaly-section {
+    margin: 0 16px 16px;
+    padding: 16px;
+  }
+  
+  .custom-date-picker {
+    margin: 0 16px 16px;
+    padding: 16px;
+    
+    .date-range-inputs {
+      flex-direction: column;
+      align-items: stretch;
+      
+      button {
+        margin-top: 12px;
+      }
+    }
+  }
+  
+  .tag-filter {
+    margin: 0 16px 16px;
+    padding: 16px;
+    
+    .tag-filter-buttons {
+      gap: 8px;
+    }
+    
+    .tag-filter-btn {
+      flex: 1 1 auto;
+      min-width: fit-content;
+      font-size: 12px;
+      padding: 6px 12px;
+    }
+  }
 }

+ 346 - 78
campus_health_app/frontend/campus-health-app/src/app/modules/weight/weight.component.ts

@@ -2,9 +2,11 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
 import { CommonModule } from '@angular/common';
 import { FormsModule } from '@angular/forms';
 import { Router, RouterModule } from '@angular/router';
-import { NgxEchartsModule } from 'ngx-echarts';
-import type { EChartsOption, SeriesOption } from 'echarts';
 import { Subject, takeUntil } from 'rxjs';
+import { ModalComponent } from '../../shared/components/modal/modal.component';
+
+// 声明 ECharts 全局类型
+declare const echarts: any;
 
 import { WeightDataService } from '../../services/weight-data.service';
 import {
@@ -18,7 +20,7 @@ import {
 @Component({
   selector: 'app-weight',
   standalone: true,
-  imports: [CommonModule, FormsModule, RouterModule, NgxEchartsModule],
+  imports: [CommonModule, FormsModule, RouterModule, ModalComponent],
   templateUrl: './weight.component.html',
   styleUrl: './weight.component.scss'
 })
@@ -67,11 +69,11 @@ export class WeightComponent implements OnInit, OnDestroy {
   tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().split('T')[0];
   Math = Math;
 
-  // ECharts 配置
-  trendChartOption: EChartsOption = {};
-  changeChartOption: EChartsOption = {};
-  scatterChartOption: EChartsOption = {};
-  progressChartOption: EChartsOption = {};
+  // ECharts 图表实例
+  trendChart: any = null;
+  changeChart: any = null;
+  scatterChart: any = null;
+  progressChart: any = null;
 
   constructor(
     private router: Router,
@@ -101,11 +103,50 @@ export class WeightComponent implements OnInit, OnDestroy {
         this.updateCharts();
         this.calculateStats();
       });
+
+    // 延迟初始化图表,确保DOM已加载
+    setTimeout(() => {
+      this.initializeChartInstances();
+    }, 100);
+
+    // 添加窗口调整大小事件监听器
+    window.addEventListener('resize', this.handleResize.bind(this));
   }
 
   ngOnDestroy(): void {
     this.destroy$.next();
     this.destroy$.complete();
+
+    // 移除事件监听器并销毁图表
+    window.removeEventListener('resize', this.handleResize.bind(this));
+    if (this.trendChart) this.trendChart.dispose();
+    if (this.changeChart) this.changeChart.dispose();
+    if (this.scatterChart) this.scatterChart.dispose();
+    if (this.progressChart) this.progressChart.dispose();
+  }
+
+  // 初始化图表实例
+  private initializeChartInstances(): void {
+    const trendDom = document.getElementById('trendChart');
+    const changeDom = document.getElementById('changeChart');
+    const scatterDom = document.getElementById('scatterChart');
+    const progressDom = document.getElementById('progressChart');
+
+    if (trendDom) this.trendChart = echarts.init(trendDom);
+    if (changeDom) this.changeChart = echarts.init(changeDom);
+    if (scatterDom) this.scatterChart = echarts.init(scatterDom);
+    if (progressDom) this.progressChart = echarts.init(progressDom);
+
+    // 初始化后立即更新图表
+    this.updateCharts();
+  }
+
+  // 处理窗口调整大小
+  handleResize(): void {
+    if (this.trendChart) this.trendChart.resize();
+    if (this.changeChart) this.changeChart.resize();
+    if (this.scatterChart) this.scatterChart.resize();
+    if (this.progressChart) this.progressChart.resize();
   }
 
   /**
@@ -168,10 +209,32 @@ export class WeightComponent implements OnInit, OnDestroy {
    * 更新趋势折线图
    */
   private updateTrendChart(): void {
+    if (!this.trendChart) return;
+
     const records = [...this.filteredRecords].reverse(); // 从旧到新排序
 
+    // 如果数据不足,显示提示信息
     if (records.length === 0) {
-      this.trendChartOption = {};
+      this.trendChart.setOption({
+        title: {
+          text: '体重趋势',
+          left: 'center',
+          textStyle: { fontSize: 16, fontWeight: 'bold', color: '#1f2937' }
+        },
+        graphic: {
+          type: 'text',
+          left: 'center',
+          top: 'center',
+          style: {
+            text: '暂无数据',
+            fontSize: 14,
+            fill: '#9ca3af',
+            textAlign: 'center'
+          }
+        },
+        xAxis: { show: false },
+        yAxis: { show: false }
+      });
       return;
     }
 
@@ -190,7 +253,7 @@ export class WeightComponent implements OnInit, OnDestroy {
     // 标注点(关键节点)
     const markPoints = this.extractMarkPoints(records);
 
-    this.trendChartOption = {
+    this.trendChart.setOption({
       title: {
         text: '体重趋势',
         left: 'center',
@@ -308,24 +371,65 @@ export class WeightComponent implements OnInit, OnDestroy {
           lineStyle: { width: 2, type: 'dotted' },
           symbol: 'none'
         }] as any[] : [])
-      ] as unknown as SeriesOption[]
-    };
+      ]
+    });
   }
 
   /**
    * 更新周/月体重变化柱状图
    */
   private updateChangeChart(): void {
+    if (!this.changeChart) return;
+
     const changes = this.calculateWeightChanges();
     
+    // 获取图表标题
+    const getChartTitle = (): string => {
+      if (this.chartPeriod === 'weekly') {
+        // 检查数据跨度以确定是显示日变化还是周变化
+        const records = [...this.filteredRecords].reverse();
+        if (records.length >= 2) {
+          const firstDate = new Date(records[0].date);
+          const lastDate = new Date(records[records.length - 1].date);
+          const daysDiff = Math.floor((lastDate.getTime() - firstDate.getTime()) / (1000 * 60 * 60 * 24));
+          return daysDiff < 14 ? '每日体重变化' : '周体重变化';
+        }
+        return '周体重变化';
+      }
+      return '月体重变化';
+    };
+    
+    const chartTitle = getChartTitle();
+    
+    // 如果数据不足,显示提示信息
     if (changes.labels.length === 0) {
-      this.changeChartOption = {};
+      this.changeChart.setOption({
+        title: {
+          text: chartTitle,
+          left: 'center',
+          textStyle: { fontSize: 16, fontWeight: 'bold', color: '#1f2937' }
+        },
+        graphic: {
+          type: 'text',
+          left: 'center',
+          top: 'center',
+          style: {
+            text: '数据不足\n需要至少2条记录',
+            fontSize: 14,
+            fill: '#9ca3af',
+            textAlign: 'center',
+            lineHeight: 24
+          }
+        },
+        xAxis: { show: false },
+        yAxis: { show: false }
+      });
       return;
     }
     
-    this.changeChartOption = {
+    this.changeChart.setOption({
       title: {
-        text: this.chartPeriod === 'weekly' ? '周体重变化' : '月体重变化',
+        text: chartTitle,
         left: 'center',
         textStyle: { fontSize: 16, fontWeight: 'bold', color: '#1f2937' }
       },
@@ -379,117 +483,263 @@ export class WeightComponent implements OnInit, OnDestroy {
           data: changes.values.map(v => ({
             value: v,
             itemStyle: {
-              color: v < 0 ? '#10b981' : '#ef4444'
+              color: v < 0 
+                ? 'rgba(16, 185, 129, 0.85)'  // 绿色(减重)
+                : 'rgba(239, 68, 68, 0.85)',  // 红色(增重)
+              borderRadius: [6, 6, 0, 0],
+              shadowColor: v < 0 
+                ? 'rgba(16, 185, 129, 0.3)' 
+                : 'rgba(239, 68, 68, 0.3)',
+              shadowBlur: 8,
+              shadowOffsetY: 3
             }
           })),
           label: {
             show: true,
-            position: 'top',
+            position: (params: any) => params.value >= 0 ? 'top' : 'bottom',
             formatter: (params: any) => {
               const value = params.value;
               const sign = value >= 0 ? '+' : '';
-              return `${sign}${value.toFixed(1)}kg`;
+              return `${sign}${value.toFixed(1)}`;
             },
-            color: '#000',
-            fontSize: 11,
-            fontWeight: 'bold'
+            color: '#1f2937',
+            fontSize: 12,
+            fontWeight: 'bold',
+            backgroundColor: 'rgba(255, 255, 255, 0.9)',
+            borderRadius: 4,
+            padding: [4, 8]
           },
-          barWidth: '60%'
+          barWidth: '55%',
+          emphasis: {
+            itemStyle: {
+              shadowBlur: 15,
+              shadowOffsetY: 5
+            }
+          }
         }
       ]
-    };
+    });
   }
 
   /**
    * 更新体重-体脂率散点图
    */
   private updateScatterChart(): void {
+    if (!this.scatterChart) return;
+
+    // 如果数据不足,显示提示信息
     if (this.filteredRecords.length === 0) {
-      this.scatterChartOption = {};
+      this.scatterChart.setOption({
+        title: {
+          text: '体重 vs 体脂率',
+          left: 'center',
+          textStyle: { fontSize: 14, fontWeight: 'bold', color: '#1f2937' }
+        },
+        graphic: {
+          type: 'text',
+          left: 'center',
+          top: 'center',
+          style: {
+            text: '暂无数据',
+            fontSize: 14,
+            fill: '#9ca3af',
+            textAlign: 'center'
+          }
+        },
+        xAxis: { show: false },
+        yAxis: { show: false }
+      });
       return;
     }
 
     const data = this.filteredRecords.map((r, index) => [r.weight, r.bodyFat, r.date, index]);
     
-    this.scatterChartOption = {
-      title: {
-        text: '体重 vs 体脂率',
-        left: 'center',
-        textStyle: { fontSize: 14, fontWeight: 'bold', color: '#1f2937' }
-      },
+    // 计算趋势线(如果有足够数据)
+    let trendLineData: any[] = [];
+    if (data.length >= 2) {
+      const weights = data.map(d => Number(d[0]));
+      const bodyFats = data.map(d => Number(d[1]));
+      const minWeight = Math.min(...weights);
+      const maxWeight = Math.max(...weights);
+      
+      // 简单线性回归
+      const avgWeight = weights.reduce((a, b) => a + b, 0) / weights.length;
+      const avgBodyFat = bodyFats.reduce((a, b) => a + b, 0) / bodyFats.length;
+      let numerator = 0, denominator = 0;
+      for (let i = 0; i < weights.length; i++) {
+        numerator += (Number(weights[i]) - avgWeight) * (Number(bodyFats[i]) - avgBodyFat);
+        denominator += (Number(weights[i]) - avgWeight) ** 2;
+      }
+      const slope = numerator / denominator;
+      const intercept = avgBodyFat - slope * avgWeight;
+      
+      trendLineData = [
+        [minWeight, slope * minWeight + intercept],
+        [maxWeight, slope * maxWeight + intercept]
+      ];
+    }
+    
+    this.scatterChart.setOption({
       tooltip: {
         formatter: (params: any) => {
+          if (params.seriesName === '趋势线') {
+            return null;
+          }
           const [weight, bodyFat, date] = params.value;
           return `
-            <div style="padding: 8px;">
-              <div style="font-weight: bold; margin-bottom: 4px;">${date}</div>
-              <div>体重: ${weight} kg</div>
-              <div>体脂率: ${bodyFat}%</div>
+            <div style="padding: 10px; font-size: 13px;">
+              <div style="font-weight: bold; margin-bottom: 6px; color: #1f2937;">${date}</div>
+              <div style="margin-bottom: 3px;"><span style="color: #667eea;">●</span> 体重: <strong>${weight} kg</strong></div>
+              <div><span style="color: #f59e0b;">●</span> 体脂率: <strong>${bodyFat}%</strong></div>
             </div>
           `;
         },
-        backgroundColor: 'rgba(255, 255, 255, 0.95)',
+        backgroundColor: 'rgba(255, 255, 255, 0.98)',
         borderColor: '#e5e7eb',
-        borderWidth: 1
+        borderWidth: 1,
+        textStyle: { color: '#374151' }
       },
       grid: {
-        left: '15%',
-        right: '10%',
+        left: '12%',
+        right: '12%',
         bottom: '15%',
-        top: '20%'
+        top: '15%',
+        containLabel: true
       },
       xAxis: {
         type: 'value',
         name: '体重 (kg)',
         nameLocation: 'middle',
         nameGap: 30,
-        nameTextStyle: { fontSize: 12 },
-        axisLabel: { fontSize: 11 }
+        nameTextStyle: { 
+          fontSize: 13, 
+          fontWeight: 'bold',
+          color: '#1f2937'
+        },
+        axisLabel: { 
+          fontSize: 11,
+          color: '#6b7280'
+        },
+        splitLine: {
+          lineStyle: {
+            type: 'dashed',
+            color: '#e5e7eb'
+          }
+        }
       },
       yAxis: {
         type: 'value',
         name: '体脂率 (%)',
         nameLocation: 'middle',
         nameGap: 40,
-        nameTextStyle: { fontSize: 12 },
-        axisLabel: { fontSize: 11 }
+        nameTextStyle: { 
+          fontSize: 13, 
+          fontWeight: 'bold',
+          color: '#1f2937'
+        },
+        axisLabel: { 
+          fontSize: 11,
+          color: '#6b7280'
+        },
+        splitLine: {
+          lineStyle: {
+            type: 'dashed',
+            color: '#e5e7eb'
+          }
+        }
       },
       visualMap: {
+        show: data.length > 1,
         min: 0,
         max: data.length - 1,
         dimension: 3,
         orient: 'vertical',
-        right: 10,
+        right: 15,
         top: 'center',
-        text: ['新', '旧'],
-        calculable: true,
-        textStyle: { fontSize: 11 },
+        text: ['最新', '最早'],
+        textStyle: { 
+          fontSize: 11,
+          color: '#6b7280'
+        },
         inRange: {
-          color: ['#d1d5db', '#3b82f6']
-        }
+          color: ['#cbd5e1', '#667eea', '#764ba2']
+        },
+        itemWidth: 15,
+        itemHeight: 100
       },
       series: [
+        // 趋势线
+        ...(trendLineData.length > 0 ? [{
+          name: '趋势线',
+          type: 'line',
+          data: trendLineData,
+          smooth: false,
+          symbol: 'none',
+          lineStyle: {
+            type: 'dashed',
+            width: 2,
+            color: '#9ca3af',
+            opacity: 0.8
+          },
+          tooltip: {
+            show: false
+          },
+          z: 0
+        }] : []),
+        // 散点
         {
+          name: '数据点',
           type: 'scatter',
           data: data,
-          symbolSize: 14,
+          symbolSize: 18,
+          itemStyle: {
+            borderWidth: 2,
+            borderColor: '#fff',
+            shadowBlur: 8,
+            shadowColor: 'rgba(102, 126, 234, 0.4)',
+            shadowOffsetY: 2
+          },
           emphasis: {
             itemStyle: {
-              shadowBlur: 10,
-              shadowColor: 'rgba(0, 0, 0, 0.5)'
+              shadowBlur: 15,
+              shadowColor: 'rgba(102, 126, 234, 0.6)',
+              borderWidth: 3,
+              scale: 1.3
             }
-          }
+          },
+          z: 10
         }
       ]
-    };
+    });
   }
 
   /**
    * 更新目标进度环形图
    */
   private updateProgressChart(): void {
+    if (!this.progressChart) return;
+
+    // 如果没有目标或数据,显示提示信息
     if (!this.goal || this.records.length === 0) {
-      this.progressChartOption = {};
+      this.progressChart.setOption({
+        title: {
+          text: '目标进度',
+          left: 'center',
+          top: 10,
+          textStyle: { fontSize: 14, fontWeight: 'bold', color: '#1f2937' }
+        },
+        graphic: {
+          type: 'text',
+          left: 'center',
+          top: 'center',
+          style: {
+            text: !this.goal ? '请先设置目标' : '暂无数据',
+            fontSize: 14,
+            fill: '#9ca3af',
+            textAlign: 'center'
+          }
+        }
+      });
       return;
     }
 
@@ -510,7 +760,7 @@ export class WeightComponent implements OnInit, OnDestroy {
       color = '#3b82f6'; // 蓝色
     }
 
-    this.progressChartOption = {
+    this.progressChart.setOption({
       title: {
         text: '目标进度',
         left: 'center',
@@ -552,7 +802,7 @@ export class WeightComponent implements OnInit, OnDestroy {
           }
         }
       ]
-    };
+    });
   }
 
   /**
@@ -612,30 +862,48 @@ export class WeightComponent implements OnInit, OnDestroy {
     }
 
     if (this.chartPeriod === 'weekly') {
-      // 按周分组
-      const weeks: { [key: string]: WeightRecord[] } = {};
+      // 先检查数据的时间跨度
+      const firstDate = new Date(records[0].date);
+      const lastDate = new Date(records[records.length - 1].date);
+      const daysDiff = Math.floor((lastDate.getTime() - firstDate.getTime()) / (1000 * 60 * 60 * 24));
       
-      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));
+      // 如果数据跨度小于14天,显示每日变化而不是周变化
+      if (daysDiff < 14) {
+        // 显示每日变化
+        for (let i = 1; i < records.length; i++) {
+          const date = new Date(records[i].date);
+          const label = `${date.getMonth() + 1}/${date.getDate()}`;
+          const change = records[i].weight - records[i - 1].weight;
           
-          labels.push(weekKey);
-          values.push(currAvg - prevAvg);
+          labels.push(label);
+          values.push(change);
         }
-      });
+      } else {
+        // 按周分组
+        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[] } = {};

+ 80 - 0
campus_health_app/frontend/campus-health-app/src/app/shared/components/modal/modal.component.html

@@ -0,0 +1,80 @@
+<!-- 弹窗遮罩层 -->
+@if (isOpen) {
+<div 
+  class="modal-overlay" 
+  (click)="onOverlayClick($event)"
+  [@modalAnimation]>
+  
+  <!-- 弹窗内容区 -->
+  <div 
+    class="modal-container"
+    [class.modal-small]="mergedConfig.size === 'small'"
+    [class.modal-medium]="mergedConfig.size === 'medium'"
+    [class.modal-large]="mergedConfig.size === 'large'"
+    [class.modal-xlarge]="mergedConfig.size === 'xlarge'"
+    [class.theme-default]="mergedConfig.theme === 'default'"
+    [class.theme-gradient]="mergedConfig.theme === 'gradient'"
+    [class.theme-success]="mergedConfig.theme === 'success'"
+    [class.theme-warning]="mergedConfig.theme === 'warning'"
+    [class.theme-danger]="mergedConfig.theme === 'danger'"
+    (click)="onContentClick($event)"
+    [@contentAnimation]>
+    
+    <!-- 头部 -->
+    @if (mergedConfig.showHeader) {
+    <div class="modal-header" [class.has-gradient]="mergedConfig.theme === 'gradient'">
+      <div class="modal-header-content">
+        @if (mergedConfig.icon) {
+        <span class="modal-icon">{{ mergedConfig.icon }}</span>
+        }
+        @if (mergedConfig.title) {
+        <h3 class="modal-title">{{ mergedConfig.title }}</h3>
+        }
+        <!-- 自定义头部插槽 -->
+        <ng-content select="[slot='header']"></ng-content>
+      </div>
+      @if (mergedConfig.showCloseButton) {
+      <button 
+        class="modal-close-btn" 
+        (click)="closeModal()"
+        type="button"
+        aria-label="关闭">
+        ×
+      </button>
+      }
+    </div>
+    }
+    
+    <!-- 主体内容 -->
+    <div class="modal-body">
+      <ng-content></ng-content>
+    </div>
+    
+    <!-- 底部操作栏 -->
+    @if (mergedConfig.showFooter) {
+    <div class="modal-footer">
+      <!-- 自定义底部插槽 -->
+      <ng-content select="[slot='footer']"></ng-content>
+      
+      <!-- 默认按钮(如果没有自定义底部) -->
+      <div class="modal-footer-actions">
+        <button 
+          type="button"
+          class="modal-btn modal-btn-cancel" 
+          (click)="onCancel()">
+          {{ mergedConfig.cancelText }}
+        </button>
+        <button 
+          type="button"
+          class="modal-btn modal-btn-confirm" 
+          (click)="onConfirm()"
+          [disabled]="mergedConfig.confirmDisabled">
+          {{ mergedConfig.confirmText }}
+        </button>
+      </div>
+    </div>
+    }
+  </div>
+</div>
+}
+

+ 708 - 0
campus_health_app/frontend/campus-health-app/src/app/shared/components/modal/modal.component.scss

@@ -0,0 +1,708 @@
+/**
+ * 通用弹窗组件样式
+ */
+
+// 遮罩层
+.modal-overlay {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0, 0, 0, 0.5);
+  backdrop-filter: blur(4px);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 9999;
+  padding: 20px;
+  overflow-y: auto;
+}
+
+// 弹窗容器
+.modal-container {
+  background: #fff;
+  border-radius: 16px;
+  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
+  max-height: calc(100vh - 40px);
+  display: flex;
+  flex-direction: column;
+  position: relative;
+  margin: auto;
+  
+  // 尺寸
+  &.modal-small {
+    width: 100%;
+    max-width: 400px;
+  }
+  
+  &.modal-medium {
+    width: 100%;
+    max-width: 600px;
+  }
+  
+  &.modal-large {
+    width: 100%;
+    max-width: 800px;
+  }
+  
+  &.modal-xlarge {
+    width: 100%;
+    max-width: 1000px;
+  }
+}
+
+// 头部
+.modal-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 20px 24px;
+  border-bottom: 1px solid #e5e7eb;
+  flex-shrink: 0;
+  
+  // 渐变主题头部
+  &.has-gradient {
+    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+    color: #fff;
+    padding: 24px;
+    border-bottom: none;
+    border-radius: 16px 16px 0 0;
+  }
+  
+  .modal-header-content {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    flex: 1;
+  }
+  
+  .modal-icon {
+    font-size: 28px;
+    line-height: 1;
+    flex-shrink: 0;
+  }
+  
+  .modal-title {
+    font-size: 20px;
+    font-weight: 700;
+    margin: 0;
+    line-height: 1.4;
+  }
+  
+  .modal-close-btn {
+    width: 36px;
+    height: 36px;
+    border-radius: 50%;
+    border: none;
+    background: rgba(0, 0, 0, 0.1);
+    color: inherit;
+    font-size: 28px;
+    line-height: 1;
+    cursor: pointer;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    transition: all 0.2s ease;
+    flex-shrink: 0;
+    
+    &:hover {
+      background: rgba(0, 0, 0, 0.15);
+      transform: rotate(90deg);
+    }
+    
+    &:active {
+      transform: rotate(90deg) scale(0.95);
+    }
+  }
+  
+  &.has-gradient .modal-close-btn {
+    background: rgba(255, 255, 255, 0.2);
+    color: #fff;
+    
+    &:hover {
+      background: rgba(255, 255, 255, 0.3);
+    }
+  }
+}
+
+// 主体
+.modal-body {
+  padding: 24px;
+  overflow-y: auto;
+  flex: 1;
+  
+  // 自定义滚动条
+  &::-webkit-scrollbar {
+    width: 8px;
+  }
+  
+  &::-webkit-scrollbar-track {
+    background: #f1f1f1;
+    border-radius: 4px;
+  }
+  
+  &::-webkit-scrollbar-thumb {
+    background: #c1c1c1;
+    border-radius: 4px;
+    
+    &:hover {
+      background: #a1a1a1;
+    }
+  }
+}
+
+// 底部
+.modal-footer {
+  padding: 20px 24px;
+  border-top: 1px solid #e5e7eb;
+  display: flex;
+  justify-content: flex-end;
+  gap: 12px;
+  flex-shrink: 0;
+  
+  .modal-footer-actions {
+    display: flex;
+    gap: 12px;
+  }
+}
+
+// 按钮
+.modal-btn {
+  padding: 10px 24px;
+  border-radius: 8px;
+  font-size: 14px;
+  font-weight: 600;
+  cursor: pointer;
+  transition: all 0.2s ease;
+  border: none;
+  
+  &:disabled {
+    opacity: 0.5;
+    cursor: not-allowed;
+  }
+  
+  &.modal-btn-cancel {
+    background: #f3f4f6;
+    color: #374151;
+    
+    &:hover:not(:disabled) {
+      background: #e5e7eb;
+    }
+  }
+  
+  &.modal-btn-confirm {
+    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+    color: #fff;
+    box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
+    
+    &:hover:not(:disabled) {
+      box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
+      transform: translateY(-1px);
+    }
+    
+    &:active:not(:disabled) {
+      transform: translateY(0);
+    }
+  }
+}
+
+// 主题样式
+.theme-success {
+  .modal-header.has-gradient {
+    background: linear-gradient(135deg, #10b981 0%, #059669 100%);
+  }
+  
+  .modal-btn-confirm {
+    background: linear-gradient(135deg, #10b981 0%, #059669 100%);
+    box-shadow: 0 2px 8px rgba(16, 185, 129, 0.3);
+    
+    &:hover:not(:disabled) {
+      box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
+    }
+  }
+}
+
+.theme-warning {
+  .modal-header.has-gradient {
+    background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
+  }
+  
+  .modal-btn-confirm {
+    background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
+    box-shadow: 0 2px 8px rgba(245, 158, 11, 0.3);
+    
+    &:hover:not(:disabled) {
+      box-shadow: 0 4px 12px rgba(245, 158, 11, 0.4);
+    }
+  }
+}
+
+.theme-danger {
+  .modal-header.has-gradient {
+    background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
+  }
+  
+  .modal-btn-confirm {
+    background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
+    box-shadow: 0 2px 8px rgba(239, 68, 68, 0.3);
+    
+    &:hover:not(:disabled) {
+      box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
+    }
+  }
+}
+
+// 弹窗内容样式辅助类
+.detail-modal-content,
+.record-modal-content,
+.weight-record-form,
+.goal-setting-form {
+  // 继承原有样式,确保内容正确显示
+  .form-group {
+    margin-bottom: 20px;
+    
+    label {
+      display: block;
+      font-size: 14px;
+      font-weight: 600;
+      color: #374151;
+      margin-bottom: 8px;
+    }
+    
+    input,
+    select,
+    textarea {
+      width: 100%;
+      padding: 10px 12px;
+      border: 1px solid #d1d5db;
+      border-radius: 8px;
+      font-size: 14px;
+      transition: all 0.2s ease;
+      
+      &:focus {
+        outline: none;
+        border-color: #667eea;
+        box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
+      }
+    }
+    
+    textarea {
+      resize: vertical;
+      min-height: 80px;
+    }
+  }
+  
+  .form-actions,
+  .dialog-actions {
+    display: flex;
+    justify-content: flex-end;
+    gap: 12px;
+    margin-top: 24px;
+    padding-top: 20px;
+    border-top: 1px solid #e5e7eb;
+  }
+  
+  .btn {
+    padding: 10px 24px;
+    border-radius: 8px;
+    font-size: 14px;
+    font-weight: 600;
+    cursor: pointer;
+    transition: all 0.2s ease;
+    border: none;
+    
+    &.btn-primary,
+    &.submit-btn {
+      background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+      color: #fff;
+      box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
+      
+      &:hover {
+        box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
+        transform: translateY(-1px);
+      }
+    }
+    
+    &.btn-secondary,
+    &.cancel-btn {
+      background: #f3f4f6;
+      color: #374151;
+      
+      &:hover {
+        background: #e5e7eb;
+      }
+    }
+  }
+  
+  // 标签选择器样式
+  .tag-selector {
+    .available-tags {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 8px;
+      margin-bottom: 12px;
+    }
+    
+    .tag-btn {
+      padding: 6px 12px;
+      border: 1px solid #d1d5db;
+      border-radius: 6px;
+      background: #fff;
+      font-size: 13px;
+      cursor: pointer;
+      transition: all 0.2s ease;
+      
+      &:hover {
+        border-color: #667eea;
+        color: #667eea;
+      }
+      
+      &.selected {
+        background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+        color: #fff;
+        border-color: transparent;
+      }
+    }
+    
+    .selected-tags {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 8px;
+      margin-bottom: 12px;
+      
+      .selected-tag {
+        display: inline-flex;
+        align-items: center;
+        gap: 6px;
+        padding: 6px 12px;
+        background: #ede9fe;
+        color: #6d28d9;
+        border-radius: 6px;
+        font-size: 13px;
+        
+        .remove-tag {
+          background: none;
+          border: none;
+          color: #6d28d9;
+          font-size: 18px;
+          line-height: 1;
+          cursor: pointer;
+          padding: 0;
+          
+          &:hover {
+            color: #5b21b6;
+          }
+        }
+      }
+    }
+    
+    .custom-tag-input {
+      display: flex;
+      gap: 8px;
+      
+      input {
+        flex: 1;
+      }
+      
+      .btn-sm {
+        padding: 8px 16px;
+        font-size: 13px;
+      }
+    }
+  }
+}
+
+// 健康报告样式
+.report-content-new {
+  .report-header {
+    text-align: center;
+    margin-bottom: 32px;
+    
+    h2 {
+      font-size: 24px;
+      font-weight: 700;
+      color: #1f2937;
+      margin: 0 0 8px 0;
+    }
+    
+    .report-date {
+      font-size: 14px;
+      color: #6b7280;
+      margin: 0;
+    }
+  }
+  
+  .health-metrics-grid {
+    display: grid;
+    grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+    gap: 20px;
+    margin-bottom: 32px;
+  }
+  
+  .metric-report-card {
+    background: linear-gradient(135deg, #f9fafb 0%, #ffffff 100%);
+    border: 1px solid #e5e7eb;
+    border-radius: 12px;
+    padding: 20px;
+    transition: all 0.3s ease;
+    
+    &:hover {
+      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
+      transform: translateY(-2px);
+    }
+    
+    .metric-card-header {
+      display: flex;
+      align-items: center;
+      gap: 12px;
+      margin-bottom: 16px;
+      
+      .metric-icon-large {
+        font-size: 32px;
+      }
+      
+      h3 {
+        font-size: 16px;
+        font-weight: 600;
+        color: #1f2937;
+        margin: 0;
+      }
+    }
+    
+    .metric-card-body {
+      .metric-main-value {
+        display: flex;
+        align-items: baseline;
+        gap: 8px;
+        margin-bottom: 12px;
+        flex-wrap: wrap;
+        
+        .value-number {
+          font-size: 28px;
+          font-weight: 700;
+          color: #1f2937;
+        }
+        
+        .value-unit {
+          font-size: 16px;
+          color: #6b7280;
+        }
+        
+        .status-badge {
+          padding: 4px 12px;
+          border-radius: 12px;
+          font-size: 12px;
+          font-weight: 600;
+          
+          &.badge-normal {
+            background: #d1fae5;
+            color: #065f46;
+          }
+          
+          &.badge-warning {
+            background: #fed7aa;
+            color: #92400e;
+          }
+          
+          &.badge-danger {
+            background: #fee2e2;
+            color: #991b1b;
+          }
+        }
+      }
+      
+      .metric-trend {
+        font-size: 13px;
+        color: #6b7280;
+        
+        .trend-label {
+          font-weight: 600;
+        }
+        
+        .trend-value {
+          &.trend-up {
+            color: #dc2626;
+          }
+          
+          &.trend-down {
+            color: #059669;
+          }
+          
+          &.trend-stable {
+            color: #6b7280;
+          }
+        }
+      }
+    }
+  }
+  
+  .advice-section-new {
+    background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
+    border-radius: 12px;
+    padding: 24px;
+    
+    .advice-header {
+      display: flex;
+      align-items: center;
+      gap: 12px;
+      margin-bottom: 16px;
+      
+      .advice-icon {
+        font-size: 28px;
+      }
+      
+      h3 {
+        font-size: 18px;
+        font-weight: 700;
+        color: #78350f;
+        margin: 0;
+      }
+    }
+    
+    .advice-list {
+      display: flex;
+      flex-direction: column;
+      gap: 12px;
+      
+      .advice-item {
+        display: flex;
+        align-items: center;
+        gap: 12px;
+        padding: 12px;
+        background: rgba(255, 255, 255, 0.6);
+        border-radius: 8px;
+        
+        .advice-item-icon {
+          font-size: 20px;
+          flex-shrink: 0;
+        }
+        
+        .advice-text {
+          font-size: 14px;
+          color: #78350f;
+          line-height: 1.5;
+        }
+        
+        &.warning {
+          background: #fee2e2;
+          
+          .advice-text {
+            color: #991b1b;
+          }
+        }
+      }
+    }
+  }
+}
+
+// 详情样式
+.metric-details {
+  .detail-item {
+    display: flex;
+    justify-content: space-between;
+    padding: 12px 0;
+    border-bottom: 1px solid #e5e7eb;
+    
+    &:last-child {
+      border-bottom: none;
+    }
+    
+    .label {
+      font-weight: 600;
+      color: #6b7280;
+    }
+    
+    .value {
+      color: #1f2937;
+      
+      &.normal {
+        color: #059669;
+      }
+      
+      &.warning {
+        color: #d97706;
+      }
+      
+      &.danger {
+        color: #dc2626;
+      }
+    }
+  }
+  
+  .advice-section {
+    margin-top: 24px;
+    padding: 16px;
+    background: #f9fafb;
+    border-radius: 8px;
+    
+    h4 {
+      font-size: 16px;
+      font-weight: 600;
+      color: #1f2937;
+      margin: 0 0 12px 0;
+    }
+    
+    p {
+      font-size: 14px;
+      color: #4b5563;
+      line-height: 1.6;
+      margin: 0;
+    }
+  }
+}
+
+// 响应式
+@media (max-width: 768px) {
+  .modal-overlay {
+    padding: 0;
+    align-items: flex-end;
+  }
+  
+  .modal-container {
+    max-height: 90vh;
+    border-radius: 16px 16px 0 0;
+    width: 100% !important;
+    max-width: 100% !important;
+    margin: 0;
+  }
+  
+  .modal-header {
+    padding: 16px 20px;
+    
+    &.has-gradient {
+      padding: 20px;
+    }
+    
+    .modal-icon {
+      font-size: 24px;
+    }
+    
+    .modal-title {
+      font-size: 18px;
+    }
+    
+    .modal-close-btn {
+      width: 32px;
+      height: 32px;
+      font-size: 24px;
+    }
+  }
+  
+  .modal-body {
+    padding: 20px;
+  }
+  
+  .modal-footer {
+    padding: 16px 20px;
+    
+    .modal-footer-actions {
+      width: 100%;
+      
+      .modal-btn {
+        flex: 1;
+      }
+    }
+  }
+}
+

+ 165 - 0
campus_health_app/frontend/campus-health-app/src/app/shared/components/modal/modal.component.ts

@@ -0,0 +1,165 @@
+import { Component, Input, Output, EventEmitter, OnInit, OnDestroy, HostListener } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { trigger, transition, style, animate } from '@angular/animations';
+import { ModalConfig, ModalSize, ModalTheme } from './modal.config';
+
+/**
+ * 通用弹窗组件
+ * 支持多种尺寸、主题和配置选项
+ */
+@Component({
+  selector: 'app-modal',
+  standalone: true,
+  imports: [CommonModule],
+  templateUrl: './modal.component.html',
+  styleUrls: ['./modal.component.scss'],
+  animations: [
+    trigger('modalAnimation', [
+      transition(':enter', [
+        style({ opacity: 0 }),
+        animate('200ms ease-out', style({ opacity: 1 }))
+      ]),
+      transition(':leave', [
+        animate('200ms ease-in', style({ opacity: 0 }))
+      ])
+    ]),
+    trigger('contentAnimation', [
+      transition(':enter', [
+        style({ transform: 'scale(0.95) translateY(-20px)', opacity: 0 }),
+        animate('250ms cubic-bezier(0.34, 1.56, 0.64, 1)', 
+          style({ transform: 'scale(1) translateY(0)', opacity: 1 }))
+      ]),
+      transition(':leave', [
+        animate('200ms ease-in', 
+          style({ transform: 'scale(0.95) translateY(-20px)', opacity: 0 }))
+      ])
+    ])
+  ]
+})
+export class ModalComponent implements OnInit, OnDestroy {
+  @Input() isOpen: boolean = false;
+  @Input() config: ModalConfig = {};
+  @Input() title?: string;
+  @Input() icon?: string;
+  @Input() size?: ModalSize;
+  @Input() theme?: ModalTheme;
+  
+  @Output() isOpenChange = new EventEmitter<boolean>();
+  @Output() confirm = new EventEmitter<void>();
+  @Output() cancel = new EventEmitter<void>();
+  @Output() close = new EventEmitter<void>();
+
+  ngOnInit(): void {
+    // 当弹窗打开时,禁止背景滚动
+    if (this.isOpen) {
+      this.disableBodyScroll();
+    }
+  }
+
+  ngOnDestroy(): void {
+    // 组件销毁时恢复滚动
+    this.enableBodyScroll();
+  }
+
+  /**
+   * 监听 ESC 键
+   */
+  @HostListener('document:keydown.escape', ['$event'])
+  onEscapeKey(event: KeyboardEvent): void {
+    if (this.isOpen && this.mergedConfig.closeOnEscape) {
+      this.closeModal();
+    }
+  }
+
+  /**
+   * 监听 isOpen 变化,控制背景滚动
+   */
+  ngOnChanges(): void {
+    if (this.isOpen) {
+      this.disableBodyScroll();
+    } else {
+      this.enableBodyScroll();
+    }
+  }
+
+  /**
+   * 合并配置(输入属性优先级高于 config 对象)
+   */
+  get mergedConfig(): Required<ModalConfig> {
+    return {
+      title: this.title || this.config.title || '',
+      icon: this.icon || this.config.icon || '',
+      size: this.size || this.config.size || 'medium',
+      theme: this.theme || this.config.theme || 'default',
+      showHeader: this.config.showHeader ?? true,
+      showFooter: this.config.showFooter ?? false,
+      showCloseButton: this.config.showCloseButton ?? true,
+      closeOnOverlayClick: this.config.closeOnOverlayClick ?? true,
+      closeOnEscape: this.config.closeOnEscape ?? true,
+      confirmText: this.config.confirmText || '确定',
+      cancelText: this.config.cancelText || '取消',
+      confirmDisabled: this.config.confirmDisabled ?? false,
+      onConfirm: this.config.onConfirm || (() => {}),
+      onCancel: this.config.onCancel || (() => {}),
+      onClose: this.config.onClose || (() => {})
+    };
+  }
+
+  /**
+   * 点击遮罩层
+   */
+  onOverlayClick(event: MouseEvent): void {
+    if (this.mergedConfig.closeOnOverlayClick) {
+      this.closeModal();
+    }
+  }
+
+  /**
+   * 阻止事件冒泡(防止点击内容区域关闭弹窗)
+   */
+  onContentClick(event: MouseEvent): void {
+    event.stopPropagation();
+  }
+
+  /**
+   * 关闭弹窗
+   */
+  closeModal(): void {
+    this.isOpen = false;
+    this.isOpenChange.emit(false);
+    this.close.emit();
+    this.mergedConfig.onClose();
+  }
+
+  /**
+   * 确认操作
+   */
+  onConfirm(): void {
+    this.confirm.emit();
+    this.mergedConfig.onConfirm();
+  }
+
+  /**
+   * 取消操作
+   */
+  onCancel(): void {
+    this.cancel.emit();
+    this.mergedConfig.onCancel();
+    this.closeModal();
+  }
+
+  /**
+   * 禁止背景滚动
+   */
+  private disableBodyScroll(): void {
+    document.body.style.overflow = 'hidden';
+  }
+
+  /**
+   * 恢复背景滚动
+   */
+  private enableBodyScroll(): void {
+    document.body.style.overflow = '';
+  }
+}
+

+ 34 - 0
campus_health_app/frontend/campus-health-app/src/app/shared/components/modal/modal.config.ts

@@ -0,0 +1,34 @@
+/**
+ * 弹窗组件配置接口
+ */
+
+export type ModalSize = 'small' | 'medium' | 'large' | 'xlarge';
+export type ModalTheme = 'default' | 'gradient' | 'success' | 'warning' | 'danger';
+
+export interface ModalConfig {
+  // 基础配置
+  title?: string;              // 标题文本
+  icon?: string;               // 标题图标 emoji
+  size?: ModalSize;            // 弹窗尺寸
+  theme?: ModalTheme;          // 主题样式
+  
+  // 样式配置
+  showHeader?: boolean;        // 是否显示头部(默认 true)
+  showFooter?: boolean;        // 是否显示底部操作栏(默认 false)
+  showCloseButton?: boolean;   // 是否显示关闭按钮(默认 true)
+  
+  // 交互配置
+  closeOnOverlayClick?: boolean;  // 点击遮罩是否关闭(默认 true)
+  closeOnEscape?: boolean;        // ESC 键是否关闭(默认 true)
+  
+  // 按钮配置(当 showFooter = true 时)
+  confirmText?: string;        // 确认按钮文本(默认"确定")
+  cancelText?: string;         // 取消按钮文本(默认"取消")
+  confirmDisabled?: boolean;   // 确认按钮是否禁用
+  
+  // 回调函数
+  onConfirm?: () => void | Promise<void>;
+  onCancel?: () => void;
+  onClose?: () => void;
+}
+

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

@@ -6,6 +6,8 @@
   <base href="/">
   <meta name="viewport" content="width=device-width, initial-scale=1">
   <link rel="icon" type="image/x-icon" href="favicon.ico">
+  <!-- ECharts 国内CDN -->
+  <script src="https://cdn.bootcdn.net/ajax/libs/echarts/5.4.3/echarts.min.js"></script>
 </head>
 <body>
   <app-root></app-root>

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä