|
|
@@ -0,0 +1,2798 @@
|
|
|
+# P0 营养师核心功能实现计划
|
|
|
+
|
|
|
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
+
|
|
|
+**Goal:** 实现3个P0功能:孩子营养档案统一视图、结构化营养缺乏分析、家长端报告可视化解读
|
|
|
+
|
|
|
+**Architecture:** 后端聚合+前端展示。数据孤岛聚合在 ChildNutritionProfileService,缺乏分析基于指标→营养素映射表量化计算,结果供前端可视化+AI推荐消费。
|
|
|
+
|
|
|
+**Tech Stack:** Spring Boot 2.7.18 + MyBatis-Plus + MySQL + uni-app Vue 2 (小程序) + Vue 2 + Element UI (Web)
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 文件结构概览
|
|
|
+
|
|
|
+```
|
|
|
+cfc-backend/src/main/java/com/etotem/cfc/
|
|
|
+├── entity/
|
|
|
+│ ├── NutritionIndicatorMapping.java # [NEW] 指标-营养素映射
|
|
|
+│ └── NutritionDeficiencyRecord.java # [NEW] 缺乏分析结果
|
|
|
+├── mapper/
|
|
|
+│ ├── NutritionIndicatorMappingMapper.java # [NEW]
|
|
|
+│ └── NutritionDeficiencyRecordMapper.java # [NEW]
|
|
|
+├── service/
|
|
|
+│ ├── ChildNutritionProfileService.java # [NEW] 档案聚合
|
|
|
+│ └── NutritionDeficiencyService.java # [NEW] 缺乏分析
|
|
|
+├── controller/
|
|
|
+│ └── HealthReportController.java # [MODIFY] 新增4个端点
|
|
|
+├── dto/
|
|
|
+│ └── HealthAnalysisResult.java # [MODIFY] 新增deficiencyRecords字段
|
|
|
+└── config/
|
|
|
+ └── DatabaseInitializer.java # [MODIFY] 新增2张表DDL+种子数据
|
|
|
+
|
|
|
+cfc-frontend/
|
|
|
+├── pages/
|
|
|
+│ ├── health/
|
|
|
+│ │ └── nutrition-profile.vue # [NEW] 营养档案统一视图
|
|
|
+│ └── body/
|
|
|
+│ ├── index.vue # [MODIFY] 导航入口
|
|
|
+│ └── health-report.vue # [MODIFY] 可视化解读
|
|
|
+└── utils/
|
|
|
+ └── api.js # [MODIFY] 新增API函数
|
|
|
+
|
|
|
+cfc-web/src/
|
|
|
+├── views/nutritionist/
|
|
|
+│ ├── NutritionMappings.vue # [NEW] 映射表CRUD
|
|
|
+│ ├── HealthReports.vue # [MODIFY] 增加缺乏分析关联
|
|
|
+│ └── HealthIndicators.vue # [MODIFY] 异常指标标签
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 执行策略:5个并行Wave
|
|
|
+
|
|
|
+> 每个Wave内任务可并行执行,Wave间有依赖顺序。
|
|
|
+
|
|
|
+```
|
|
|
+Wave 1 (DB基础层 — 3 Tasks):
|
|
|
+├── T1: DatabaseInitializer 新增2张表DDL + 15条种子数据
|
|
|
+├── T2: NutritionIndicatorMapping Entity + Mapper
|
|
|
+└── T3: NutritionDeficiencyRecord Entity + Mapper
|
|
|
+
|
|
|
+Wave 2 (核心服务 — 2 Tasks, after Wave1):
|
|
|
+├── T4: NutritionDeficiencyService 实现
|
|
|
+└── T5: ChildNutritionProfileService 实现
|
|
|
+
|
|
|
+Wave 3 (API端点 — 3 Tasks, after Wave2):
|
|
|
+├── T6: HealthReportController 新增4个端点
|
|
|
+├── T7: HealthAnalysisService 链式调用缺乏分析
|
|
|
+└── T8: MealRecommendService.buildDifyInputs 增加deficiency_matrix
|
|
|
+
|
|
|
+Wave 4 (小程序前端 — 4 Tasks, after Wave3):
|
|
|
+├── T9: pages/health/nutrition-profile.vue 新页面
|
|
|
+├── T10: pages/body/health-report.vue 改造(解读+趋势+雷达+矩阵)
|
|
|
+├── T11: pages/body/index.vue 导航入口
|
|
|
+└── T12: utils/api.js 新增4个API函数
|
|
|
+
|
|
|
+Wave 5 (Web管理端 — 3 Tasks, after Wave3):
|
|
|
+├── T13: nutritionist/NutritionMappings.vue 新页面
|
|
|
+├── T14: nutritionist/HealthReports.vue 改造
|
|
|
+└── T15: nutritionist/HealthIndicators.vue 改造
|
|
|
+```
|
|
|
+
|
|
|
+**关键路径**: T1 → T2/T3 → T4/T5 → T6/T7/T8 → T9/T10/T11/T12 + T13/T14/T15 (并行)
|
|
|
+**预计总Tasks**: 15个
|
|
|
+**并行执行**: Wave内并行,Wave间串行
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Wave 1: DB基础层
|
|
|
+
|
|
|
+### T1: DatabaseInitializer 新增2张表DDL + 种子数据
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java` — 在 `initData()` 方法末尾添加DDL + 种子SQL
|
|
|
+
|
|
|
+**参考文件**: `DatabaseInitializer.java:initData()` 方法结构
|
|
|
+
|
|
|
+- [ ] **Step 1: 找到 DatabaseInitializer.java 并阅读 initData() 结构**
|
|
|
+
|
|
|
+文件路径:`cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`
|
|
|
+找到 `initData()` 方法,在方法末尾(return 之前)添加新增表的SQL
|
|
|
+
|
|
|
+- [ ] **Step 2: 添加 nutrition_indicator_mapping 表 DDL**
|
|
|
+
|
|
|
+在 `initData()` 方法 return 前插入以下SQL(使用 `jdbcTemplate.execute(sql)`):
|
|
|
+
|
|
|
+```java
|
|
|
+// 创建 nutrition_indicator_mapping 表
|
|
|
+jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS nutrition_indicator_mapping (" +
|
|
|
+ "id BIGINT AUTO_INCREMENT PRIMARY KEY," +
|
|
|
+ "indicator_name_pattern VARCHAR(200) NOT NULL COMMENT '指标名匹配模式(SQL LIKE语法,%为通配符)'," +
|
|
|
+ "nutrient VARCHAR(50) NOT NULL COMMENT '关联营养素名'," +
|
|
|
+ "severity_weight DECIMAL(3,2) DEFAULT 1.00 COMMENT '权重(0-1)'," +
|
|
|
+ "deficiency_symptoms TEXT COMMENT '缺乏症状描述'," +
|
|
|
+ "excess_symptoms TEXT COMMENT '过量症状描述'," +
|
|
|
+ "food_suggestions TEXT COMMENT '推荐食物(逗号分隔)'," +
|
|
|
+ "sort_order INT DEFAULT 0 COMMENT '排序(大值优先匹配)'," +
|
|
|
+ "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," +
|
|
|
+ "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," +
|
|
|
+ "INDEX idx_nutrient (nutrient)" +
|
|
|
+ ") COMMENT='指标-营养素映射表'");
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 添加 nutrition_deficiency_record 表 DDL**
|
|
|
+
|
|
|
+```java
|
|
|
+jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS nutrition_deficiency_record (" +
|
|
|
+ "id BIGINT AUTO_INCREMENT PRIMARY KEY," +
|
|
|
+ "child_id BIGINT NOT NULL COMMENT '孩子ID'," +
|
|
|
+ "report_id BIGINT NOT NULL COMMENT '关联报告ID'," +
|
|
|
+ "nutrient VARCHAR(50) NOT NULL COMMENT '营养素名'," +
|
|
|
+ "deficiency_type VARCHAR(20) NOT NULL COMMENT 'deficiency/excess/borderline/unclassified'," +
|
|
|
+ "severity DECIMAL(5,2) DEFAULT 0 COMMENT '严重程度(0-1)'," +
|
|
|
+ "indicator_name VARCHAR(100) COMMENT '关联指标名'," +
|
|
|
+ "indicator_value VARCHAR(50) COMMENT '指标检测值'," +
|
|
|
+ "ref_range VARCHAR(100) COMMENT '参考范围'," +
|
|
|
+ "symptoms TEXT COMMENT '症状描述'," +
|
|
|
+ "food_suggestions TEXT COMMENT '推荐食物'," +
|
|
|
+ "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," +
|
|
|
+ "INDEX idx_child (child_id)," +
|
|
|
+ "INDEX idx_report (report_id)," +
|
|
|
+ "INDEX idx_nutrient (nutrient)" +
|
|
|
+ ") COMMENT='营养缺乏分析记录'");
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 添加15条种子数据**
|
|
|
+
|
|
|
+```java
|
|
|
+// 插入15条指标-营养素映射种子数据(使用 batchUpdate 或逐条 execute)
|
|
|
+String[][] seedData = {
|
|
|
+ {"铁蛋白", "铁", "1.00", "乏力、面色苍白、注意力不集中", "铁过量可能损伤肝脏", "动物肝脏,红肉,菠菜,黑木耳", "10"},
|
|
|
+ {"25-羟基维生素D", "维生素D", "1.00", "影响钙吸收、骨骼发育", "维生素D过量可致高钙血症", "三文鱼,蛋黄,蘑菇,强化牛奶", "20"},
|
|
|
+ {"维生素D", "维生素D", "1.00", "影响钙吸收、骨骼发育、免疫力下降", "维生素D过量可致高钙血症", "三文鱼,蛋黄,蘑菇,强化牛奶", "15"},
|
|
|
+ {"锌", "锌", "0.80", "食欲减退、味觉异常、免疫力降低", "锌过量影响铜吸收", "牡蛎,牛肉,南瓜子,扁豆", "10"},
|
|
|
+ {"钙", "钙", "0.80", "骨骼发育迟缓、牙齿问题", "高钙血症、肾结石", "牛奶,豆腐,虾皮,芝麻酱", "10"},
|
|
|
+ {"维生素B12", "维生素B12", "1.00", "贫血、神经发育迟缓", "一般不易过量", "牛肉,鸡蛋,牛奶,鱼肉", "10"},
|
|
|
+ {"维生素C", "维生素C", "0.60", "免疫力下降、牙龈出血", "腹泻、肾结石风险", "猕猴桃,橙子,草莓,青椒", "10"},
|
|
|
+ {"白蛋白", "蛋白质", "0.90", "发育迟缓、肌肉萎缩", "肾脏负担", "鸡蛋,鱼肉,牛奶,豆制品", "10"},
|
|
|
+ {"总蛋白", "蛋白质", "0.90", "营养不良、免疫力下降", "血液粘稠", "鸡蛋,鱼肉,牛奶,豆制品", "9"},
|
|
|
+ {"膳食纤维", "膳食纤维", "0.50", "便秘、肠道菌群失衡", "腹胀、排便过多", "全谷物,蔬菜,豆类,水果", "10"},
|
|
|
+ {"叶酸", "叶酸", "0.70", "贫血、发育迟缓", "一般不易过量", "菠菜,动物肝脏,豆类,柑橘", "10"},
|
|
|
+ {"维生素A", "维生素A", "0.70", "视力问题、皮肤干燥、免疫力下降", "头痛、肝脏损伤", "胡萝卜,动物肝脏,南瓜,菠菜", "10"},
|
|
|
+ {"硒", "硒", "0.50", "免疫力下降、甲状腺功能异常", "脱发、指甲脆裂", "巴西坚果,海鲜,蘑菇", "10"},
|
|
|
+ {"镁", "镁", "0.40", "肌肉痉挛、睡眠问题", "腹泻、低血压", "坚果,全谷物,深绿蔬菜", "10"},
|
|
|
+ {"益生菌%", "益生菌", "0.60", "肠道菌群失衡、消化问题", "一般不易过量", "酸奶,泡菜,味噌,康普茶", "10"}
|
|
|
+};
|
|
|
+
|
|
|
+for (String[] row : seedData) {
|
|
|
+ jdbcTemplate.update("INSERT IGNORE INTO nutrition_indicator_mapping " +
|
|
|
+ "(indicator_name_pattern, nutrient, severity_weight, deficiency_symptoms, excess_symptoms, food_suggestions, sort_order) " +
|
|
|
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
|
+ row[0], row[1], new BigDecimal(row[2]), row[3], row[4], row[5], Integer.parseInt(row[6]));
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: 编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd cfc-backend && mvn clean compile -q
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 6: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
|
|
|
+git commit -m "feat(health): add nutrition_indicator_mapping and nutrition_deficiency_record tables"
|
|
|
+```
|
|
|
+
|
|
|
+**QA验证**:
|
|
|
+- 启动后端,`jdbcTemplate` 会在应用启动时自动建表并插入种子数据
|
|
|
+- 验证: `mysql -h 192.168.16.251 -u cfc -pcfc@123 cfc -e "SELECT COUNT(*) FROM nutrition_indicator_mapping;"` → 预期15
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### T2: NutritionIndicatorMapping Entity + Mapper
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/entity/NutritionIndicatorMapping.java`
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/mapper/NutritionIndicatorMappingMapper.java`
|
|
|
+
|
|
|
+**参考文件**: 参考现有 entity 如 `HealthIndicator.java` 的结构和 `@TableName` 用法
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 NutritionIndicatorMapping.java**
|
|
|
+
|
|
|
+文件路径:`cfc-backend/src/main/java/com/etotem/cfc/entity/NutritionIndicatorMapping.java`
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.entity;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.annotation.IdType;
|
|
|
+import com.baomidou.mybatisplus.annotation.TableId;
|
|
|
+import com.baomidou.mybatisplus.annotation.TableName;
|
|
|
+import lombok.Data;
|
|
|
+import java.math.BigDecimal;
|
|
|
+import java.time.LocalDateTime;
|
|
|
+
|
|
|
+@Data
|
|
|
+@TableName("nutrition_indicator_mapping")
|
|
|
+public class NutritionIndicatorMapping {
|
|
|
+
|
|
|
+ @TableId(type = IdType.AUTO)
|
|
|
+ private Long id;
|
|
|
+
|
|
|
+ /** 指标名匹配模式(SQL LIKE语法, %为通配符) */
|
|
|
+ private String indicatorNamePattern;
|
|
|
+
|
|
|
+ /** 关联营养素名 */
|
|
|
+ private String nutrient;
|
|
|
+
|
|
|
+ /** 权重(0-1) */
|
|
|
+ private BigDecimal severityWeight;
|
|
|
+
|
|
|
+ /** 缺乏症状描述 */
|
|
|
+ private String deficiencySymptoms;
|
|
|
+
|
|
|
+ /** 过量症状描述 */
|
|
|
+ private String excessSymptoms;
|
|
|
+
|
|
|
+ /** 推荐食物(逗号分隔) */
|
|
|
+ private String foodSuggestions;
|
|
|
+
|
|
|
+ /** 排序(大值优先匹配) */
|
|
|
+ private Integer sortOrder;
|
|
|
+
|
|
|
+ private LocalDateTime createdAt;
|
|
|
+ private LocalDateTime updatedAt;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 创建 NutritionIndicatorMappingMapper.java**
|
|
|
+
|
|
|
+文件路径:`cfc-backend/src/main/java/com/etotem/cfc/mapper/NutritionIndicatorMappingMapper.java`
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.mapper;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
|
|
+import com.etotem.cfc.entity.NutritionIndicatorMapping;
|
|
|
+import org.apache.ibatis.annotations.Mapper;
|
|
|
+import org.apache.ibatis.annotations.Param;
|
|
|
+import org.apache.ibatis.annotations.Select;
|
|
|
+import java.util.List;
|
|
|
+
|
|
|
+@Mapper
|
|
|
+public interface NutritionIndicatorMappingMapper extends BaseMapper<NutritionIndicatorMapping> {
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 按指标名模糊匹配映射规则(按sort_order倒序,取第一条)
|
|
|
+ * 使用 LIKE 语法匹配 indicatorNamePattern
|
|
|
+ */
|
|
|
+ @Select("SELECT * FROM nutrition_indicator_mapping " +
|
|
|
+ "WHERE #{indicatorName} LIKE indicator_name_pattern " +
|
|
|
+ "ORDER BY sort_order DESC LIMIT 1")
|
|
|
+ NutritionIndicatorMapping findMappingByIndicatorName(@Param("indicatorName") String indicatorName);
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取所有映射规则(用于Web管理端列表)
|
|
|
+ */
|
|
|
+ @Select("SELECT * FROM nutrition_indicator_mapping ORDER BY sort_order DESC, nutrient ASC")
|
|
|
+ List<NutritionIndicatorMapping> findAllOrderBySort();
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd cfc-backend && mvn clean compile -q
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/NutritionIndicatorMapping.java
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/mapper/NutritionIndicatorMappingMapper.java
|
|
|
+git commit -m "feat(health): add NutritionIndicatorMapping entity and mapper"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### T3: NutritionDeficiencyRecord Entity + Mapper
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/entity/NutritionDeficiencyRecord.java`
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/mapper/NutritionDeficiencyRecordMapper.java`
|
|
|
+
|
|
|
+**参考文件**: 参考现有 entity 如 `HealthIndicator.java`
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 NutritionDeficiencyRecord.java**
|
|
|
+
|
|
|
+文件路径:`cfc-backend/src/main/java/com/etotem/cfc/entity/NutritionDeficiencyRecord.java`
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.entity;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.annotation.IdType;
|
|
|
+import com.baomidou.mybatisplus.annotation.TableId;
|
|
|
+import com.baomidou.mybatisplus.annotation.TableName;
|
|
|
+import lombok.Data;
|
|
|
+import java.math.BigDecimal;
|
|
|
+import java.time.LocalDateTime;
|
|
|
+
|
|
|
+@Data
|
|
|
+@TableName("nutrition_deficiency_record")
|
|
|
+public class NutritionDeficiencyRecord {
|
|
|
+
|
|
|
+ @TableId(type = IdType.AUTO)
|
|
|
+ private Long id;
|
|
|
+
|
|
|
+ /** 孩子ID */
|
|
|
+ private Long childId;
|
|
|
+
|
|
|
+ /** 关联报告ID */
|
|
|
+ private Long reportId;
|
|
|
+
|
|
|
+ /** 营养素名 */
|
|
|
+ private String nutrient;
|
|
|
+
|
|
|
+ /** 缺乏类型: deficiency/excess/borderline/unclassified */
|
|
|
+ private String deficiencyType;
|
|
|
+
|
|
|
+ /** 严重程度(0-1) */
|
|
|
+ private BigDecimal severity;
|
|
|
+
|
|
|
+ /** 关联指标名 */
|
|
|
+ private String indicatorName;
|
|
|
+
|
|
|
+ /** 指标检测值 */
|
|
|
+ private String indicatorValue;
|
|
|
+
|
|
|
+ /** 参考范围 */
|
|
|
+ private String refRange;
|
|
|
+
|
|
|
+ /** 症状描述 */
|
|
|
+ private String symptoms;
|
|
|
+
|
|
|
+ /** 推荐食物 */
|
|
|
+ private String foodSuggestions;
|
|
|
+
|
|
|
+ private LocalDateTime createdAt;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 创建 NutritionDeficiencyRecordMapper.java**
|
|
|
+
|
|
|
+文件路径:`cfc-backend/src/main/java/com/etotem/cfc/mapper/NutritionDeficiencyRecordMapper.java`
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.mapper;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
|
|
+import com.etotem.cfc.entity.NutritionDeficiencyRecord;
|
|
|
+import org.apache.ibatis.annotations.Delete;
|
|
|
+import org.apache.ibatis.annotations.Mapper;
|
|
|
+import org.apache.ibatis.annotations.Param;
|
|
|
+import org.apache.ibatis.annotations.Select;
|
|
|
+import java.util.List;
|
|
|
+
|
|
|
+@Mapper
|
|
|
+public interface NutritionDeficiencyRecordMapper extends BaseMapper<NutritionDeficiencyRecord> {
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取指定报告的缺乏分析结果
|
|
|
+ */
|
|
|
+ @Select("SELECT * FROM nutrition_deficiency_record WHERE report_id = #{reportId} ORDER BY severity DESC")
|
|
|
+ List<NutritionDeficiencyRecord> findByReportId(@Param("reportId") Long reportId);
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取孩子的最新缺乏分析结果(取最新报告的)
|
|
|
+ */
|
|
|
+ @Select("SELECT r.* FROM nutrition_deficiency_record r " +
|
|
|
+ "INNER JOIN (SELECT report_id FROM nutrition_deficiency_record WHERE child_id = #{childId} ORDER BY created_at DESC LIMIT 1) latest " +
|
|
|
+ "ON r.report_id = latest.report_id " +
|
|
|
+ "ORDER BY r.severity DESC")
|
|
|
+ List<NutritionDeficiencyRecord> findLatestByChildId(@Param("childId") Long childId);
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取孩子的缺乏趋势(多报告)
|
|
|
+ */
|
|
|
+ @Select("SELECT r.nutrient, r.severity, r.deficiency_type, " +
|
|
|
+ "FROM nutrition_deficiency_record r " +
|
|
|
+ "INNER JOIN health_reports hr ON r.report_id = hr.id " +
|
|
|
+ "WHERE r.child_id = #{childId} " +
|
|
|
+ "ORDER BY hr.report_date DESC, r.severity DESC")
|
|
|
+ List<NutritionDeficiencyRecord> findTrendByChildId(@Param("childId") Long childId);
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 删除指定报告的分析结果(幂等重分析用)
|
|
|
+ */
|
|
|
+ @Delete("DELETE FROM nutrition_deficiency_record WHERE report_id = #{reportId}")
|
|
|
+ int deleteByReportId(@Param("reportId") Long reportId);
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取孩子已有分析报告的日期列表(用于趋势)
|
|
|
+ */
|
|
|
+ @Select("SELECT DISTINCT DATE(r.created_at) as report_date FROM nutrition_deficiency_record r " +
|
|
|
+ "WHERE r.child_id = #{childId} ORDER BY report_date DESC")
|
|
|
+ List<String> findReportDatesByChildId(@Param("childId") Long childId);
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd cfc-backend && mvn clean compile -q
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/NutritionDeficiencyRecord.java
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/mapper/NutritionDeficiencyRecordMapper.java
|
|
|
+git commit -m "feat(health): add NutritionDeficiencyRecord entity and mapper"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Wave 2: 核心服务层
|
|
|
+
|
|
|
+### T4: NutritionDeficiencyService 实现
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/service/NutritionDeficiencyService.java`
|
|
|
+
|
|
|
+**参考文件**: `HealthAnalysisService.java` (现有分析服务逻辑), `HealthIndicator.java`, `NutritionIndicatorMappingMapper.java`
|
|
|
+
|
|
|
+**依赖**: T2 + T3 (entity+mapper已就绪)
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建基础服务骨架**
|
|
|
+
|
|
|
+文件路径:`cfc-backend/src/main/java/com/etotem/cfc/service/NutritionDeficiencyService.java`
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.etotem.cfc.entity.HealthIndicator;
|
|
|
+import com.etotem.cfc.entity.NutritionDeficiencyRecord;
|
|
|
+import com.etotem.cfc.entity.NutritionIndicatorMapping;
|
|
|
+import com.etotem.cfc.mapper.NutritionDeficiencyRecordMapper;
|
|
|
+import com.etotem.cfc.mapper.NutritionIndicatorMappingMapper;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.math.BigDecimal;
|
|
|
+import java.math.RoundingMode;
|
|
|
+import java.util.*;
|
|
|
+import java.util.stream.Collectors;
|
|
|
+
|
|
|
+@Service
|
|
|
+public class NutritionDeficiencyService {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private NutritionIndicatorMappingMapper mappingMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private NutritionDeficiencyRecordMapper deficiencyRecordMapper;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 实现 analyzeDeficiencies(reportId, childId) 方法**
|
|
|
+
|
|
|
+在 `NutritionDeficiencyService.java` 中添加:
|
|
|
+
|
|
|
+```java
|
|
|
+/**
|
|
|
+ * 对指定报告执行营养缺乏分析,结果持久化
|
|
|
+ * @return 分析结果列表
|
|
|
+ */
|
|
|
+public List<NutritionDeficiencyRecord> analyzeDeficiencies(Long reportId, Long childId) {
|
|
|
+ // 1. 获取该报告的所有异常指标(status != "正常")
|
|
|
+ List<HealthIndicator> abnormalIndicators = getAbnormalIndicators(reportId);
|
|
|
+ if (abnormalIndicators.isEmpty()) {
|
|
|
+ return Collections.emptyList();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2. 幂等:删除该报告已有分析结果
|
|
|
+ deficiencyRecordMapper.deleteByReportId(reportId);
|
|
|
+
|
|
|
+ // 3. 获取所有映射规则(缓存优化)
|
|
|
+ List<NutritionIndicatorMapping> allMappings = mappingMapper.selectList(null);
|
|
|
+
|
|
|
+ // 4. 遍历异常指标,进行匹配和分析
|
|
|
+ List<NutritionDeficiencyRecord> results = new ArrayList<>();
|
|
|
+ for (HealthIndicator indicator : abnormalIndicators) {
|
|
|
+ // 使用指标名匹配映射表(按sort_order倒序取第一条)
|
|
|
+ NutritionIndicatorMapping mapping = findBestMapping(indicator.getIndicatorName(), allMappings);
|
|
|
+
|
|
|
+ if (mapping != null) {
|
|
|
+ // 匹配到映射规则 → 结构化缺乏记录
|
|
|
+ NutritionDeficiencyRecord record = buildRecord(childId, reportId, indicator, mapping);
|
|
|
+ results.add(record);
|
|
|
+ }
|
|
|
+ // 未匹配 → 不生成记录(渐进增强原则:有数据展示,无数据跳过)
|
|
|
+ }
|
|
|
+
|
|
|
+ // 5. 同一营养素多条记录 → 合并(取severity最高)
|
|
|
+ List<NutritionDeficiencyRecord> merged = mergeByNutrient(results);
|
|
|
+
|
|
|
+ // 6. 批量插入数据库
|
|
|
+ if (!merged.isEmpty()) {
|
|
|
+ for (NutritionDeficiencyRecord record : merged) {
|
|
|
+ deficiencyRecordMapper.insert(record);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return merged;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 获取报告的异常指标列表
|
|
|
+ * 这里假设HealthIndicatorService有getIndicatorsByReportId方法
|
|
|
+ */
|
|
|
+private List<HealthIndicator> getAbnormalIndicators(Long reportId) {
|
|
|
+ // TODO: 实际从HealthIndicatorService获取,暂用空实现占位
|
|
|
+ // 实现时应调用: healthIndicatorService.getIndicatorsByReportId(reportId)
|
|
|
+ // 并过滤 status != "正常"
|
|
|
+ return Collections.emptyList();
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 查找最佳匹配映射(sort_order最大的)
|
|
|
+ */
|
|
|
+private NutritionIndicatorMapping findBestMapping(String indicatorName, List<NutritionIndicatorMapping> mappings) {
|
|
|
+ for (NutritionIndicatorMapping m : mappings) {
|
|
|
+ String pattern = m.getIndicatorNamePattern();
|
|
|
+ // SQL LIKE 语义: % 匹配任意字符
|
|
|
+ if (matchLike(indicatorName, pattern)) {
|
|
|
+ return m;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * SQL LIKE 匹配实现
|
|
|
+ * pattern中的%匹配零个或多个任意字符
|
|
|
+ */
|
|
|
+private boolean matchLike(String text, String pattern) {
|
|
|
+ // 将SQL LIKE pattern转换为Java正则
|
|
|
+ String regex = pattern.replace("%", ".*");
|
|
|
+ return text.matches(regex);
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 构建缺乏记录
|
|
|
+ */
|
|
|
+private NutritionDeficiencyRecord buildRecord(Long childId, Long reportId,
|
|
|
+ HealthIndicator indicator, NutritionIndicatorMapping mapping) {
|
|
|
+ NutritionDeficiencyRecord record = new NutritionDeficiencyRecord();
|
|
|
+ record.setChildId(childId);
|
|
|
+ record.setReportId(reportId);
|
|
|
+ record.setNutrient(mapping.getNutrient());
|
|
|
+ record.setIndicatorName(indicator.getIndicatorName());
|
|
|
+ record.setIndicatorValue(indicator.getIndicatorValue());
|
|
|
+ record.setRefRange(indicator.getRefRange());
|
|
|
+ record.setSymptoms(buildSymptoms(indicator, mapping));
|
|
|
+ record.setFoodSuggestions(mapping.getFoodSuggestions());
|
|
|
+
|
|
|
+ // 计算严重程度
|
|
|
+ BigDecimal severity = calculateSeverity(indicator, mapping);
|
|
|
+ record.setSeverity(severity);
|
|
|
+
|
|
|
+ // 确定缺乏类型
|
|
|
+ String deficiencyType = determineDeficiencyType(indicator.getStatus());
|
|
|
+ record.setDeficiencyType(deficiencyType);
|
|
|
+
|
|
|
+ return record;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 计算严重程度
|
|
|
+ * 偏低: severity = severity_weight * (1 - actual/ref_min), 截断到[0,1]
|
|
|
+ * 偏高: severity = severity_weight * (actual/ref_max - 1), 截断到[0,1]
|
|
|
+ * 无法数值化: severity = severity_weight * 0.5
|
|
|
+ */
|
|
|
+private BigDecimal calculateSeverity(HealthIndicator indicator, NutritionIndicatorMapping mapping) {
|
|
|
+ String status = indicator.getStatus();
|
|
|
+ String value = indicator.getIndicatorValue();
|
|
|
+ String refRange = indicator.getRefRange();
|
|
|
+
|
|
|
+ // 尝试解析参考范围
|
|
|
+ double refMin = 0, refMax = 0;
|
|
|
+ if (refRange != null && refRange.contains("-")) {
|
|
|
+ try {
|
|
|
+ String[] parts = refRange.split("-");
|
|
|
+ refMin = Double.parseDouble(parts[0].trim());
|
|
|
+ refMax = Double.parseDouble(parts[1].trim());
|
|
|
+ } catch (NumberFormatException e) {
|
|
|
+ // 无法解析,使用默认权重
|
|
|
+ return mapping.getSeverityWeight().multiply(new BigDecimal("0.5"));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 尝试解析实际值
|
|
|
+ double actual;
|
|
|
+ try {
|
|
|
+ actual = Double.parseDouble(value);
|
|
|
+ } catch (NumberFormatException e) {
|
|
|
+ // 无法数值化,使用中等严重度
|
|
|
+ return mapping.getSeverityWeight().multiply(new BigDecimal("0.5"));
|
|
|
+ }
|
|
|
+
|
|
|
+ BigDecimal weight = mapping.getSeverityWeight();
|
|
|
+ double severity = 0;
|
|
|
+
|
|
|
+ if ("偏低".equals(status) || "缺乏".equals(status)) {
|
|
|
+ if (refMin > 0) {
|
|
|
+ severity = weight.doubleValue() * Math.min(1.0, Math.max(0, 1 - actual / refMin));
|
|
|
+ }
|
|
|
+ } else if ("偏高".equals(status) || "过量".equals(status)) {
|
|
|
+ if (refMax > 0) {
|
|
|
+ severity = weight.doubleValue() * Math.min(1.0, Math.max(0, actual / refMax - 1));
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ // 临界/边界
|
|
|
+ severity = weight.doubleValue() * 0.3;
|
|
|
+ }
|
|
|
+
|
|
|
+ return BigDecimal.valueOf(severity).setScale(4, RoundingMode.HALF_UP);
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 确定缺乏类型
|
|
|
+ */
|
|
|
+private String determineDeficiencyType(String status) {
|
|
|
+ if ("偏低".equals(status) || "缺乏".equals(status)) {
|
|
|
+ return "deficiency";
|
|
|
+ } else if ("偏高".equals(status) || "过量".equals(status)) {
|
|
|
+ return "excess";
|
|
|
+ } else if ("注意".equals(status) || "临界".equals(status)) {
|
|
|
+ return "borderline";
|
|
|
+ }
|
|
|
+ return "deficiency";
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 构建症状描述(指标症状+映射表症状合并)
|
|
|
+ */
|
|
|
+private String buildSymptoms(HealthIndicator indicator, NutritionIndicatorMapping mapping) {
|
|
|
+ String indicatorSymptoms = indicator.getSymptoms();
|
|
|
+ String mappingSymptoms = mapping.getDeficiencySymptoms();
|
|
|
+ StringBuilder sb = new StringBuilder();
|
|
|
+ if (mappingSymptoms != null && !mappingSymptoms.isEmpty()) {
|
|
|
+ sb.append(mappingSymptoms);
|
|
|
+ }
|
|
|
+ if (indicatorSymptoms != null && !indicatorSymptoms.isEmpty()) {
|
|
|
+ if (sb.length() > 0) sb.append(";");
|
|
|
+ sb.append(indicator.getIndicatorName()).append(": ").append(indicatorSymptoms);
|
|
|
+ }
|
|
|
+ return sb.toString();
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 按营养素合并(取severity最高的)
|
|
|
+ */
|
|
|
+private List<NutritionDeficiencyRecord> mergeByNutrient(List<NutritionDeficiencyRecord> records) {
|
|
|
+ Map<String, NutritionDeficiencyRecord> map = new LinkedHashMap<>();
|
|
|
+ for (NutritionDeficiencyRecord r : records) {
|
|
|
+ String nutrient = r.getNutrient();
|
|
|
+ if (!map.containsKey(nutrient) || r.getSeverity().compareTo(map.get(nutrient).getSeverity()) > 0) {
|
|
|
+ map.put(nutrient, r);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return new ArrayList<>(map.values());
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 实现 getLatestDeficiencies(childId) 方法**
|
|
|
+
|
|
|
+```java
|
|
|
+/**
|
|
|
+ * 获取孩子最新缺乏分析结果
|
|
|
+ */
|
|
|
+public List<NutritionDeficiencyRecord> getLatestDeficiencies(Long childId) {
|
|
|
+ List<NutritionDeficiencyRecord> records = deficiencyRecordMapper.findLatestByChildId(childId);
|
|
|
+ return records != null ? records : Collections.emptyList();
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 实现 getDeficiencyTrend(childId) 方法**
|
|
|
+
|
|
|
+```java
|
|
|
+/**
|
|
|
+ * 获取孩子缺乏趋势
|
|
|
+ * 返回结构: [{ nutrient, records: [{ reportDate, severity, deficiencyType }] }]
|
|
|
+ */
|
|
|
+public List<Map<String, Object>> getDeficiencyTrend(Long childId) {
|
|
|
+ List<String> dates = deficiencyRecordMapper.findReportDatesByChildId(childId);
|
|
|
+ if (dates == null || dates.isEmpty()) {
|
|
|
+ return Collections.emptyList();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 获取所有记录
|
|
|
+ List<NutritionDeficiencyRecord> allRecords = deficiencyRecordMapper.findTrendByChildId(childId);
|
|
|
+
|
|
|
+ // 按营养素分组
|
|
|
+ Map<String, List<Map<String, Object>>> nutrientGroups = new LinkedHashMap<>();
|
|
|
+ for (NutritionDeficiencyRecord r : allRecords) {
|
|
|
+ String nutrient = r.getNutrient();
|
|
|
+ Map<String, Object> item = new HashMap<>();
|
|
|
+ item.put("reportDate", r.getCreatedAt().toLocalDate().toString());
|
|
|
+ item.put("severity", r.getSeverity());
|
|
|
+ item.put("deficiencyType", r.getDeficiencyType());
|
|
|
+ nutrientGroups.computeIfAbsent(nutrient, k -> new ArrayList<>()).add(item);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 转换为 [{nutrient, records}] 结构
|
|
|
+ List<Map<String, Object>> result = new ArrayList<>();
|
|
|
+ for (Map.Entry<String, List<Map<String, Object>>> entry : nutrientGroups.entrySet()) {
|
|
|
+ Map<String, Object> nutrientTrend = new HashMap<>();
|
|
|
+ nutrientTrend.put("nutrient", entry.getKey());
|
|
|
+ nutrientTrend.put("records", entry.getValue());
|
|
|
+ result.add(nutrientTrend);
|
|
|
+ }
|
|
|
+ return result;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: 注入 HealthIndicatorService 依赖,并实现 getAbnormalIndicators**
|
|
|
+
|
|
|
+首先读取现有的 HealthIndicatorService 确认方法签名,然后在 NutritionDeficiencyService 中注入并使用:
|
|
|
+
|
|
|
+```java
|
|
|
+@Resource
|
|
|
+private com.etotem.cfc.service.HealthIndicatorService healthIndicatorService;
|
|
|
+
|
|
|
+private List<HealthIndicator> getAbnormalIndicators(Long reportId) {
|
|
|
+ // 获取该报告所有指标,过滤异常
|
|
|
+ List<HealthIndicator> all = healthIndicatorService.getIndicatorsByReportId(reportId);
|
|
|
+ if (all == null) {
|
|
|
+ return Collections.emptyList();
|
|
|
+ }
|
|
|
+ return all.stream()
|
|
|
+ .filter(ind -> ind.getStatus() != null && !"正常".equals(ind.getStatus()))
|
|
|
+ .collect(Collectors.toList());
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+**注意**: 如果 `HealthIndicatorService` 没有 `getIndicatorsByReportId` 方法,需要先在 `HealthIndicatorService` 中添加此方法(参考 `HealthReportService` 中类似方法)
|
|
|
+
|
|
|
+- [ ] **Step 6: 编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd cfc-backend && mvn clean compile -q
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 7: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/NutritionDeficiencyService.java
|
|
|
+git commit -m "feat(health): implement NutritionDeficiencyService with analyze/getLatest/getTrend"
|
|
|
+```
|
|
|
+
|
|
|
+**QA验证**:
|
|
|
+- 启动后端,调用 `/api/health/deficiency/analyze` 端点(等T6实现后)
|
|
|
+- 或直接插入测试数据到 health_indicators 表验证分析逻辑
|
|
|
+
|
|
|
+---### T5: ChildNutritionProfileService 实现
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/service/ChildNutritionProfileService.java`
|
|
|
+
|
|
|
+**参考文件**: `HealthReportService.java` (报告聚合), `MealRecommendService.java` (aggregateContext)
|
|
|
+
|
|
|
+**依赖**: T1 (表已建) — 与T4独立并行
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建服务骨架**
|
|
|
+
|
|
|
+文件路径:`cfc-backend/src/main/java/com/etotem/cfc/service/ChildNutritionProfileService.java`
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.etotem.cfc.entity.*;
|
|
|
+import com.etotem.cfc.mapper.*;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.util.*;
|
|
|
+import java.util.stream.Collectors;
|
|
|
+
|
|
|
+@Service
|
|
|
+public class ChildNutritionProfileService {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private HealthReportService healthReportService;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private HealthIndicatorService healthIndicatorService;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private HealthCheckinService healthCheckinService;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private MealLogService mealLogService;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private GrowthRecordService growthRecordService;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private UserNutritionProfileService userNutritionProfileService;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private NutritionDeficiencyService nutritionDeficiencyService;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private UserService userService;
|
|
|
+
|
|
|
+ public Map<String, Object> getNutritionProfile(Long childId) {
|
|
|
+ Map<String, Object> profile = new LinkedHashMap<>();
|
|
|
+ profile.put("childId", childId);
|
|
|
+
|
|
|
+ HealthReport latestReport = healthReportService.getLatestReport(childId);
|
|
|
+ if (latestReport != null) {
|
|
|
+ profile.put("scores", extractScores(latestReport));
|
|
|
+ } else {
|
|
|
+ profile.put("scores", null);
|
|
|
+ }
|
|
|
+
|
|
|
+ List<Map<String, Object>> abnormalIndicators = getAbnormalIndicators(childId, latestReport);
|
|
|
+ profile.put("abnormalIndicators", abnormalIndicators);
|
|
|
+
|
|
|
+ if (latestReport != null) {
|
|
|
+ List<HealthIndicator> allIndicators = healthIndicatorService.getIndicatorsByReportId(latestReport.getId());
|
|
|
+ profile.put("normalIndicatorCount", allIndicators.stream().filter(i -> "正常".equals(i.getStatus())).count());
|
|
|
+ profile.put("totalIndicatorCount", allIndicators.size());
|
|
|
+ }
|
|
|
+
|
|
|
+ profile.put("scoreTrend", getScoreTrend(childId));
|
|
|
+
|
|
|
+ if (latestReport != null) {
|
|
|
+ Map<String, Object> detail = healthReportService.getReportDetail(latestReport.getId());
|
|
|
+ profile.put("diseaseRisks", detail != null ? detail.get("diseaseRisks") : Collections.emptyList());
|
|
|
+ } else {
|
|
|
+ profile.put("diseaseRisks", Collections.emptyList());
|
|
|
+ }
|
|
|
+
|
|
|
+ profile.put("recentNutrition", getRecentNutrition(childId));
|
|
|
+ profile.put("checkinSummary", getCheckinSummary(childId));
|
|
|
+
|
|
|
+ List<HealthReport> reports = healthReportService.getUserReports(childId, 0, 3);
|
|
|
+ profile.put("reportCount", reports != null ? reports.size() : 0);
|
|
|
+ profile.put("latestReports", reports != null ?
|
|
|
+ reports.stream().limit(3).map(this::toReportSummary).collect(Collectors.toList()) :
|
|
|
+ Collections.emptyList());
|
|
|
+
|
|
|
+ Long userId = getUserIdByChildId(childId);
|
|
|
+ if (userId != null) {
|
|
|
+ profile.put("nutritionProfile", userNutritionProfileService.getByUserId(userId));
|
|
|
+ }
|
|
|
+
|
|
|
+ return profile;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Map<String, Object> extractScores(HealthReport report) {
|
|
|
+ Map<String, Object> scores = new LinkedHashMap<>();
|
|
|
+ scores.put("overall", report.getOverallScore());
|
|
|
+ scores.put("gutHealth", report.getGutHealthScore());
|
|
|
+ scores.put("nutrition", report.getNutritionScore());
|
|
|
+ scores.put("chronicDisease", report.getChronicDiseaseScore());
|
|
|
+ scores.put("gutBalance", report.getGutBalanceScore());
|
|
|
+ scores.put("gutDiversity", report.getGutDiversityScore());
|
|
|
+ scores.put("beneficialBacteria", report.getBeneficialBacteriaScore());
|
|
|
+ scores.put("harmfulBacteria", report.getHarmfulBacteriaScore());
|
|
|
+ scores.put("coreSpecies", report.getCoreSpeciesScore());
|
|
|
+ return scores;
|
|
|
+ }
|
|
|
+
|
|
|
+ private List<Map<String, Object>> getAbnormalIndicators(Long childId, HealthReport report) {
|
|
|
+ if (report == null) return Collections.emptyList();
|
|
|
+ List<HealthIndicator> all = healthIndicatorService.getIndicatorsByReportId(report.getId());
|
|
|
+ if (all == null) return Collections.emptyList();
|
|
|
+
|
|
|
+ return all.stream()
|
|
|
+ .filter(i -> i.getStatus() != null && !"正常".equals(i.getStatus()))
|
|
|
+ .sorted((a, b) -> {
|
|
|
+ int pa = priorityOf(a.getStatus());
|
|
|
+ int pb = priorityOf(b.getStatus());
|
|
|
+ if (pa != pb) return pb - pa;
|
|
|
+ return a.getIndicatorName().compareTo(b.getIndicatorName());
|
|
|
+ })
|
|
|
+ .limit(5)
|
|
|
+ .map(this::toIndicatorMap)
|
|
|
+ .collect(Collectors.toList());
|
|
|
+ }
|
|
|
+
|
|
|
+ private int priorityOf(String status) {
|
|
|
+ if ("偏低".equals(status) || "偏高".equals(status)) return 2;
|
|
|
+ if ("注意".equals(status) || "临界".equals(status)) return 1;
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Map<String, Object> toIndicatorMap(HealthIndicator i) {
|
|
|
+ Map<String, Object> m = new HashMap<>();
|
|
|
+ m.put("id", i.getId());
|
|
|
+ m.put("indicatorName", i.getIndicatorName());
|
|
|
+ m.put("indicatorValue", i.getIndicatorValue());
|
|
|
+ m.put("unit", i.getUnit());
|
|
|
+ m.put("refRange", i.getRefRange());
|
|
|
+ m.put("status", i.getStatus());
|
|
|
+ m.put("category", i.getCategory());
|
|
|
+ m.put("symptoms", i.getSymptoms());
|
|
|
+ m.put("priority", priorityOf(i.getStatus()) == 2 ? "high" : priorityOf(i.getStatus()) == 1 ? "medium" : "low");
|
|
|
+ return m;
|
|
|
+ }
|
|
|
+
|
|
|
+ private List<Map<String, Object>> getScoreTrend(Long childId) {
|
|
|
+ List<HealthReport> reports = healthReportService.getUserReports(childId, 0, 10);
|
|
|
+ if (reports == null || reports.isEmpty()) return Collections.emptyList();
|
|
|
+
|
|
|
+ return reports.stream()
|
|
|
+ .sorted(Comparator.comparing(HealthReport::getReportDate))
|
|
|
+ .limit(3)
|
|
|
+ .map(r -> {
|
|
|
+ Map<String, Object> item = new HashMap<>();
|
|
|
+ item.put("reportDate", r.getReportDate());
|
|
|
+ item.put("overall", r.getOverallScore());
|
|
|
+ item.put("gutHealth", r.getGutHealthScore());
|
|
|
+ item.put("nutrition", r.getNutritionScore());
|
|
|
+ return item;
|
|
|
+ })
|
|
|
+ .collect(Collectors.toList());
|
|
|
+ }
|
|
|
+
|
|
|
+ private Map<String, Object> getRecentNutrition(Long childId) {
|
|
|
+ Map<String, Object> nutrition = new HashMap<>();
|
|
|
+ nutrition.put("period", "7d");
|
|
|
+ nutrition.put("avgDailyCalories", 1600);
|
|
|
+ Map<String, Map<String, Object>> coverage = new LinkedHashMap<>();
|
|
|
+ coverage.put("protein", Map.of("ratio", 0.8, "label", "蛋白质"));
|
|
|
+ coverage.put("fiber", Map.of("ratio", 0.4, "label", "膳食纤维"));
|
|
|
+ coverage.put("vitaminC", Map.of("ratio", 0.6, "label", "维生素C"));
|
|
|
+ coverage.put("calcium", Map.of("ratio", 0.7, "label", "钙"));
|
|
|
+ nutrition.put("nutrientCoverage", coverage);
|
|
|
+ return nutrition;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Map<String, Object> getCheckinSummary(Long childId) {
|
|
|
+ List<HealthCheckin> checkins = healthCheckinService.getRecentCheckins(childId, 30);
|
|
|
+ Map<String, Object> summary = new HashMap<>();
|
|
|
+ summary.put("monthTotal", checkins != null ? checkins.size() : 0);
|
|
|
+ List<Map<String, Object>> recent7 = new ArrayList<>();
|
|
|
+ if (checkins != null) {
|
|
|
+ checkins.stream().limit(7).forEach(c -> {
|
|
|
+ Map<String, Object> item = new HashMap<>();
|
|
|
+ item.put("date", c.getCheckinDate());
|
|
|
+ item.put("behaviors", parseBehaviors(c.getBehaviors()));
|
|
|
+ item.put("mood", c.getMood());
|
|
|
+ recent7.add(item);
|
|
|
+ });
|
|
|
+ }
|
|
|
+ summary.put("recent7Days", recent7);
|
|
|
+ return summary;
|
|
|
+ }
|
|
|
+
|
|
|
+ private List<String> parseBehaviors(String behaviorsJson) {
|
|
|
+ if (behaviorsJson == null || behaviorsJson.isEmpty()) return Collections.emptyList();
|
|
|
+ try {
|
|
|
+ return Arrays.asList(behaviorsJson.replaceAll("[\\[\\]\"]", "").split(","));
|
|
|
+ } catch (Exception e) {
|
|
|
+ return Collections.emptyList();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private Map<String, Object> toReportSummary(HealthReport r) {
|
|
|
+ Map<String, Object> m = new HashMap<>();
|
|
|
+ m.put("id", r.getId());
|
|
|
+ m.put("reportType", r.getReportType());
|
|
|
+ m.put("overallScore", r.getOverallScore());
|
|
|
+ m.put("reportDate", r.getReportDate());
|
|
|
+ return m;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Long getUserIdByChildId(Long childId) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 确认/补全依赖方法**
|
|
|
+
|
|
|
+在实现前需确认以下方法存在:
|
|
|
+1. `HealthIndicatorService.getIndicatorsByReportId(reportId)` — 如不存在需添加
|
|
|
+2. `HealthCheckinService.getRecentCheckins(childId, days)` — 如不存在需添加
|
|
|
+3. `UserNutritionProfileService` + `UserNutritionProfileMapper` — 如不存在需创建
|
|
|
+4. `MealLogService` 获取近7天营养摘要 — 暂无则保持TODO占位
|
|
|
+
|
|
|
+- [ ] **Step 3: 编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd cfc-backend && mvn clean compile -q
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/ChildNutritionProfileService.java
|
|
|
+git commit -m "feat(health): add ChildNutritionProfileService for unified nutrition profile"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Wave 3: API端点
|
|
|
+
|
|
|
+### T6: HealthReportController 新增4个端点
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java`
|
|
|
+
|
|
|
+**参考文件**: `HealthReportController.java` — 查看现有端点结构(@PostMapping + Result包装 + @RequestAttribute)
|
|
|
+
|
|
|
+**依赖**: T4 (NutritionDeficiencyService) + T5 (ChildNutritionProfileService)
|
|
|
+
|
|
|
+- [ ] **Step 1: 阅读 HealthReportController.java 确认端点风格**
|
|
|
+
|
|
|
+文件路径:`cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java`
|
|
|
+
|
|
|
+找到现有端点的写法(注意:所有端点都是 `@PostMapping`,使用 `Result.success(data)` 返回)
|
|
|
+
|
|
|
+- [ ] **Step 2: 在 HealthReportController 末尾添加4个新端点**
|
|
|
+
|
|
|
+在类末尾添加以下方法(保持与现有风格一致,使用 `@Resource` 注入 Service):
|
|
|
+
|
|
|
+```java
|
|
|
+@Resource
|
|
|
+private ChildNutritionProfileService childNutritionProfileService;
|
|
|
+
|
|
|
+@Resource
|
|
|
+private NutritionDeficiencyService nutritionDeficiencyService;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 孩子营养档案统一视图 — 聚合API
|
|
|
+ */
|
|
|
+@PostMapping("/nutrition-profile")
|
|
|
+public Result<Map<String, Object>> getNutritionProfile(
|
|
|
+ @RequestBody Map<String, Object> params,
|
|
|
+ @RequestAttribute("userId") Long userId) {
|
|
|
+ Long childId = params.get("childId") != null ?
|
|
|
+ Long.valueOf(params.get("childId").toString()) : null;
|
|
|
+ if (childId == null) {
|
|
|
+ return Result.error("childId不能为空");
|
|
|
+ }
|
|
|
+ // TODO: 校验用户是否有权查看该孩子
|
|
|
+ Map<String, Object> profile = childNutritionProfileService.getNutritionProfile(childId);
|
|
|
+ return Result.success(profile);
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 触发营养缺乏分析
|
|
|
+ */
|
|
|
+@PostMapping("/deficiency/analyze")
|
|
|
+public Result<List<NutritionDeficiencyRecord>> analyzeDeficiency(
|
|
|
+ @RequestBody Map<String, Object> params,
|
|
|
+ @RequestAttribute("userId") Long userId) {
|
|
|
+ Long reportId = params.get("reportId") != null ?
|
|
|
+ Long.valueOf(params.get("reportId").toString()) : null;
|
|
|
+ Long childId = params.get("childId") != null ?
|
|
|
+ Long.valueOf(params.get("childId").toString()) : null;
|
|
|
+ if (reportId == null || childId == null) {
|
|
|
+ return Result.error("reportId和childId不能为空");
|
|
|
+ }
|
|
|
+ List<NutritionDeficiencyRecord> results = nutritionDeficiencyService.analyzeDeficiencies(reportId, childId);
|
|
|
+ return Result.success(results);
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 获取孩子最新缺乏分析数据
|
|
|
+ */
|
|
|
+@PostMapping("/deficiency/latest")
|
|
|
+public Result<List<NutritionDeficiencyRecord>> getLatestDeficiencies(
|
|
|
+ @RequestBody Map<String, Object> params,
|
|
|
+ @RequestAttribute("userId") Long userId) {
|
|
|
+ Long childId = params.get("childId") != null ?
|
|
|
+ Long.valueOf(params.get("childId").toString()) : null;
|
|
|
+ if (childId == null) {
|
|
|
+ return Result.error("childId不能为空");
|
|
|
+ }
|
|
|
+ List<NutritionDeficiencyRecord> results = nutritionDeficiencyService.getLatestDeficiencies(childId);
|
|
|
+ return Result.success(results);
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 获取孩子缺乏趋势
|
|
|
+ */
|
|
|
+@PostMapping("/deficiency/trend")
|
|
|
+public Result<List<Map<String, Object>>> getDeficiencyTrend(
|
|
|
+ @RequestBody Map<String, Object> params,
|
|
|
+ @RequestAttribute("userId") Long userId) {
|
|
|
+ Long childId = params.get("childId") != null ?
|
|
|
+ Long.valueOf(params.get("childId").toString()) : null;
|
|
|
+ if (childId == null) {
|
|
|
+ return Result.error("childId不能为空");
|
|
|
+ }
|
|
|
+ List<Map<String, Object>> trend = nutritionDeficiencyService.getDeficiencyTrend(childId);
|
|
|
+ return Result.success(trend);
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+**注意**: 如果 HealthReportController 中没有导入 `NutritionDeficiencyRecord`,需要添加 import:
|
|
|
+```java
|
|
|
+import com.etotem.cfc.entity.NutritionDeficiencyRecord;
|
|
|
+import com.etotem.cfc.service.ChildNutritionProfileService;
|
|
|
+import com.etotem.cfc.service.NutritionDeficiencyService;
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd cfc-backend && mvn clean compile -q
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java
|
|
|
+git commit -m "feat(api): add 4 health deficiency and profile endpoints"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### T7: HealthAnalysisService 链式调用缺乏分析
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/HealthAnalysisService.java`
|
|
|
+
|
|
|
+**参考文件**: `HealthAnalysisService.java` — 在 `analyze()` 方法末尾链式调用
|
|
|
+
|
|
|
+**依赖**: T4 (NutritionDeficiencyService 已就绪)
|
|
|
+
|
|
|
+- [ ] **Step 1: 阅读 HealthAnalysisService.java 找到 analyze() 方法末尾**
|
|
|
+
|
|
|
+文件路径:`cfc-backend/src/main/java/com/etotem/cfc/service/HealthAnalysisService.java`
|
|
|
+
|
|
|
+找到 `analyze()` 方法,在方法末尾(return result 之前)添加链式调用
|
|
|
+
|
|
|
+- [ ] **Step 2: 注入 NutritionDeficiencyService**
|
|
|
+
|
|
|
+在类顶部注入:
|
|
|
+```java
|
|
|
+@Resource
|
|
|
+private NutritionDeficiencyService nutritionDeficiencyService;
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 在 analyze() 末尾添加链式调用**
|
|
|
+
|
|
|
+在 `analyze()` 方法的 `return result;` 之前插入:
|
|
|
+
|
|
|
+```java
|
|
|
+// 链式触发营养缺乏分析(异步或同步均可)
|
|
|
+try {
|
|
|
+ nutritionDeficiencyService.analyzeDeficiencies(reportId, childId);
|
|
|
+} catch (Exception e) {
|
|
|
+ // 缺乏分析失败不影响主流程,降级处理
|
|
|
+ log.warn("营养缺乏分析失败, reportId={}, childId={}, error={}", reportId, childId, e.getMessage());
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 修改 HealthAnalysisResult 增加 deficiencyRecords 字段**
|
|
|
+
|
|
|
+文件路径:`cfc-backend/src/main/java/com/etotem/cfc/dto/HealthAnalysisResult.java`
|
|
|
+
|
|
|
+添加字段:
|
|
|
+```java
|
|
|
+private List<NutritionDeficiencyRecord> deficiencyRecords;
|
|
|
+
|
|
|
+// getter/setter
|
|
|
+public List<NutritionDeficiencyRecord> getDeficiencyRecords() { return deficiencyRecords; }
|
|
|
+public void setDeficiencyRecords(List<NutritionDeficiencyRecord> deficiencyRecords) { this.deficiencyRecords = deficiencyRecords; }
|
|
|
+```
|
|
|
+
|
|
|
+并在 analyze() 返回前,将分析结果写入 result:
|
|
|
+```java
|
|
|
+result.setDeficiencyRecords(nutritionDeficiencyService.getLatestDeficiencies(childId));
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: 编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd cfc-backend && mvn clean compile -q
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 6: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/HealthAnalysisService.java
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/dto/HealthAnalysisResult.java
|
|
|
+git commit -m "feat(health): chain NutritionDeficiencyService in HealthAnalysisService"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### T8: MealRecommendService.buildDifyInputs 增加 deficiency_matrix
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/MealRecommendService.java`
|
|
|
+
|
|
|
+**参考文件**: `MealRecommendService.java` — 找到 `buildDifyInputs()` 方法
|
|
|
+
|
|
|
+**依赖**: T4 (NutritionDeficiencyService) + T6 (API端点已注册)
|
|
|
+
|
|
|
+- [ ] **Step 1: 阅读 MealRecommendService.java 找到 buildDifyInputs() 方法**
|
|
|
+
|
|
|
+文件路径:`cfc-backend/src/main/java/com/etotem/cfc/service/MealRecommendService.java`
|
|
|
+
|
|
|
+找到 `buildDifyInputs()` 方法,确认当前返回的 JSON 结构
|
|
|
+
|
|
|
+- [ ] **Step 2: 注入 NutritionDeficiencyService**
|
|
|
+
|
|
|
+在类顶部注入:
|
|
|
+```java
|
|
|
+@Resource
|
|
|
+private NutritionDeficiencyService nutritionDeficiencyService;
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 在 buildDifyInputs() 的返回 Map 中增加 deficiency_matrix 字段**
|
|
|
+
|
|
|
+在方法返回的 Map 中添加(获取结构化缺乏数据):
|
|
|
+
|
|
|
+```java
|
|
|
+// 获取结构化营养缺乏数据
|
|
|
+List<NutritionDeficiencyRecord> deficiencyRecords = nutritionDeficiencyService.getLatestDeficiencies(childId);
|
|
|
+if (deficiencyRecords != null && !deficiencyRecords.isEmpty()) {
|
|
|
+ List<Map<String, Object>> deficiencyMatrix = new ArrayList<>();
|
|
|
+ for (NutritionDeficiencyRecord record : deficiencyRecords) {
|
|
|
+ Map<String, Object> item = new LinkedHashMap<>();
|
|
|
+ item.put("nutrient", record.getNutrient());
|
|
|
+ item.put("type", record.getDeficiencyType());
|
|
|
+ item.put("severity", record.getSeverity());
|
|
|
+ item.put("symptoms", record.getSymptoms());
|
|
|
+ // food_suggestions 是逗号分隔字符串,转为 List
|
|
|
+ if (record.getFoodSuggestions() != null && !record.getFoodSuggestions().isEmpty()) {
|
|
|
+ item.put("food_suggestions", Arrays.asList(record.getFoodSuggestions().split(",")));
|
|
|
+ } else {
|
|
|
+ item.put("food_suggestions", Collections.emptyList());
|
|
|
+ }
|
|
|
+ deficiencyMatrix.add(item);
|
|
|
+ }
|
|
|
+ result.put("deficiency_matrix", deficiencyMatrix);
|
|
|
+} else {
|
|
|
+ result.put("deficiency_matrix", Collections.emptyList());
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+将此代码块插入到 `result.put("abnormal_indicators", ...)` 之后
|
|
|
+
|
|
|
+- [ ] **Step 4: 编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd cfc-backend && mvn clean compile -q
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/MealRecommendService.java
|
|
|
+git commit -m "feat(meal): add deficiency_matrix to Dify inputs for smarter recipe recommendations"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Wave 4: 小程序前端
|
|
|
+
|
|
|
+### T9: pages/health/nutrition-profile.vue 新页面
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-frontend/pages/health/nutrition-profile.vue`
|
|
|
+- Modify: `cfc-frontend/pages.json` — 注册新页面
|
|
|
+
|
|
|
+**参考文件**: `cfc-frontend/pages/body/health-report.vue` (现有报告页结构), `cfc-frontend/pages.json` (页面注册格式)
|
|
|
+
|
|
|
+**依赖**: T6 (API端点已就绪) + T12 (api.js新增API)
|
|
|
+
|
|
|
+- [ ] **Step 1: 阅读 pages.json 确认页面注册格式**
|
|
|
+
|
|
|
+找到现有页面注册格式,如:
|
|
|
+```json
|
|
|
+{
|
|
|
+ "path": "pages/body/health-report",
|
|
|
+ "style": { ... }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 创建 pages/health 目录(如果不存在)并创建 nutrition-profile.vue**
|
|
|
+
|
|
|
+文件路径:`cfc-frontend/pages/health/nutrition-profile.vue`
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <view class="nutrition-profile">
|
|
|
+ <!-- 头部:孩子信息 -->
|
|
|
+ <view class="profile-header">
|
|
|
+ <view class="child-info">
|
|
|
+ <text class="child-name">{{ childName || '加载中...' }}</text>
|
|
|
+ <text class="child-age">{{ childAge }}岁 · {{ childGender }}</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 综合评分圆环(空状态时隐藏) -->
|
|
|
+ <view class="score-section" v-if="scores">
|
|
|
+ <view class="main-score-ring">
|
|
|
+ <circle-progress :percent="scores.overall" :size="160" :strokeWidth="12" color="#F97316" />
|
|
|
+ <view class="main-score-text">
|
|
|
+ <text class="score-value">{{ scores.overall }}</text>
|
|
|
+ <text class="score-label">综合评分</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ <view class="score-items">
|
|
|
+ <view class="score-item">
|
|
|
+ <text class="score-num">{{ scores.gutHealth }}</text>
|
|
|
+ <text class="score-name">肠道健康</text>
|
|
|
+ </view>
|
|
|
+ <view class="score-item">
|
|
|
+ <text class="score-num">{{ scores.nutrition }}</text>
|
|
|
+ <text class="score-name">营养状况</text>
|
|
|
+ </view>
|
|
|
+ <view class="score-item">
|
|
|
+ <text class="score-num">{{ scores.chronicDisease }}</text>
|
|
|
+ <text class="score-name">慢病控制</text>
|
|
|
+ </view>
|
|
|
+ <view class="score-item">
|
|
|
+ <text class="score-num">{{ scores.gutBalance }}</text>
|
|
|
+ <text class="score-name">菌群平衡</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 空状态 -->
|
|
|
+ <view class="empty-state" v-if="!scores">
|
|
|
+ <text class="empty-icon">📋</text>
|
|
|
+ <text class="empty-title">暂无健康报告</text>
|
|
|
+ <text class="empty-desc">完成首次健康检测后,这里将展示孩子的营养档案</text>
|
|
|
+ <button class="empty-btn" @click="goToAssessment">预约健康检测</button>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 需关注指标 -->
|
|
|
+ <view class="section" v-if="abnormalIndicators && abnormalIndicators.length > 0">
|
|
|
+ <view class="section-title">⚠️ 需关注指标</view>
|
|
|
+ <view class="indicator-list">
|
|
|
+ <view class="indicator-item" v-for="item in abnormalIndicators" :key="item.id">
|
|
|
+ <view class="indicator-left">
|
|
|
+ <text class="indicator-status" :class="getPriorityClass(item.priority)">
|
|
|
+ {{ getStatusEmoji(item.status) }}
|
|
|
+ </text>
|
|
|
+ <view class="indicator-info">
|
|
|
+ <text class="indicator-name">{{ item.indicatorName }}</text>
|
|
|
+ <text class="indicator-value">{{ item.indicatorValue }}{{ item.unit }} · {{ item.status }}</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ <view class="section-more" @click="goToIndicators">查看全部指标 →</view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 变化趋势 -->
|
|
|
+ <view class="section" v-if="scoreTrend && scoreTrend.length > 1">
|
|
|
+ <view class="section-title">📊 变化趋势</view>
|
|
|
+ <view class="trend-chart">
|
|
|
+ <!-- 使用 uni-canvas 绘制折线图 -->
|
|
|
+ <canvas canvas-id="trendCanvas" id="trendCanvas" class="trend-canvas"></canvas>
|
|
|
+ </view>
|
|
|
+ <view class="trend-legend">
|
|
|
+ <view class="legend-item">
|
|
|
+ <text class="legend-color" style="background:#F97316"></text>
|
|
|
+ <text>综合评分</text>
|
|
|
+ </view>
|
|
|
+ <view class="legend-item">
|
|
|
+ <text class="legend-color" style="background:#10B981"></text>
|
|
|
+ <text>肠道健康</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 营养缺乏矩阵 -->
|
|
|
+ <view class="section" v-if="deficiencyRecords && deficiencyRecords.length > 0">
|
|
|
+ <view class="section-title">🔬 营养缺乏分析</view>
|
|
|
+ <view class="deficiency-list">
|
|
|
+ <view class="deficiency-item" v-for="record in deficiencyRecords" :key="record.nutrient">
|
|
|
+ <view class="deficiency-info">
|
|
|
+ <text class="deficiency-name">{{ record.nutrient }}</text>
|
|
|
+ <text class="deficiency-level">{{ getDeficiencyLabel(record) }}</text>
|
|
|
+ </view>
|
|
|
+ <view class="deficiency-bar">
|
|
|
+ <view class="deficiency-fill" :style="{ width: (record.severity * 100) + '%', background: getSeverityColor(record.severity) }"></view>
|
|
|
+ </view>
|
|
|
+ <text class="deficiency-foods">{{ record.foodSuggestions }}</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 近7天饮食 -->
|
|
|
+ <view class="section" v-if="recentNutrition">
|
|
|
+ <view class="section-title">🍽️ 近7天饮食</view>
|
|
|
+ <view class="nutrition-coverage">
|
|
|
+ <view class="coverage-item" v-for="(val, key) in recentNutrition.nutrientCoverage" :key="key">
|
|
|
+ <text class="coverage-label">{{ val.label }}</text>
|
|
|
+ <view class="coverage-bar-wrap">
|
|
|
+ <view class="coverage-bar">
|
|
|
+ <view class="coverage-fill" :style="{ width: (val.ratio * 100) + '%' }"></view>
|
|
|
+ </view>
|
|
|
+ <text class="coverage-percent">{{ Math.round(val.ratio * 100) }}%</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 健康打卡 -->
|
|
|
+ <view class="section" v-if="checkinSummary">
|
|
|
+ <view class="section-title">🏃 健康打卡</view>
|
|
|
+ <text class="checkin-total">本月打卡 {{ checkinSummary.monthTotal }} 天</text>
|
|
|
+ <view class="checkin-recent">
|
|
|
+ <view class="checkin-day" v-for="day in checkinSummary.recent7Days" :key="day.date">
|
|
|
+ <text class="day-date">{{ formatDate(day.date) }}</text>
|
|
|
+ <view class="day-behaviors">
|
|
|
+ <text v-for="b in day.behaviors" :key="b" class="behavior-tag">{{ b }}</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 报告列表 -->
|
|
|
+ <view class="section" v-if="latestReports && latestReports.length > 0">
|
|
|
+ <view class="section-title">📋 报告列表</view>
|
|
|
+ <view class="report-list">
|
|
|
+ <view class="report-item" v-for="r in latestReports" :key="r.id" @click="goToReport(r.id)">
|
|
|
+ <view class="report-left">
|
|
|
+ <text class="report-type">{{ getReportTypeName(r.reportType) }}</text>
|
|
|
+ <text class="report-date">{{ r.reportDate }}</text>
|
|
|
+ </view>
|
|
|
+ <view class="report-right">
|
|
|
+ <text class="report-score">{{ r.overallScore }}分</text>
|
|
|
+ <text class="report-arrow">→</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ <view class="section-more" @click="goToReportList">查看全部 →</view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- AI推荐入口 -->
|
|
|
+ <view class="section">
|
|
|
+ <button class="ai-btn" @click="goToAIRecommend">🥗 获取AI个性化推荐</button>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+export default {
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ childId: null,
|
|
|
+ childName: '',
|
|
|
+ childAge: 0,
|
|
|
+ childGender: '',
|
|
|
+ scores: null,
|
|
|
+ abnormalIndicators: [],
|
|
|
+ normalIndicatorCount: 0,
|
|
|
+ totalIndicatorCount: 0,
|
|
|
+ scoreTrend: [],
|
|
|
+ diseaseRisks: [],
|
|
|
+ recentNutrition: null,
|
|
|
+ checkinSummary: null,
|
|
|
+ reportCount: 0,
|
|
|
+ latestReports: [],
|
|
|
+ nutritionProfile: null,
|
|
|
+ deficiencyRecords: []
|
|
|
+ };
|
|
|
+ },
|
|
|
+ onLoad(options) {
|
|
|
+ this.childId = options.childId || 1;
|
|
|
+ this.loadProfile();
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ loadProfile() {
|
|
|
+ this.$api.getNutritionProfile(this.childId).then(res => {
|
|
|
+ if (res.code === 200 && res.data) {
|
|
|
+ const d = res.data;
|
|
|
+ this.scores = d.scores;
|
|
|
+ this.abnormalIndicators = d.abnormalIndicators || [];
|
|
|
+ this.normalIndicatorCount = d.normalIndicatorCount || 0;
|
|
|
+ this.totalIndicatorCount = d.totalIndicatorCount || 0;
|
|
|
+ this.scoreTrend = d.scoreTrend || [];
|
|
|
+ this.diseaseRisks = d.diseaseRisks || [];
|
|
|
+ this.reportCount = d.reportCount || 0;
|
|
|
+ this.latestReports = d.latestReports || [];
|
|
|
+ this.nutritionProfile = d.nutritionProfile;
|
|
|
+ // 营养偏好
|
|
|
+ if (d.nutritionProfile) {
|
|
|
+ this.nutritionProfile = d.nutritionProfile;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // 分别加载缺乏分析数据
|
|
|
+ this.$api.getDeficiencyAnalysis(this.childId).then(res => {
|
|
|
+ if (res.code === 200 && res.data) {
|
|
|
+ this.deficiencyRecords = res.data;
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // TODO: 加载饮食摘要数据(等 mealLogService 完善)
|
|
|
+ },
|
|
|
+ getPriorityClass(priority) {
|
|
|
+ if (priority === 'high') return 'priority-high';
|
|
|
+ if (priority === 'medium') return 'priority-medium';
|
|
|
+ return '';
|
|
|
+ },
|
|
|
+ getStatusEmoji(status) {
|
|
|
+ if (status === '偏低') return '🔴';
|
|
|
+ if (status === '偏高') return '🔴';
|
|
|
+ if (status === '注意') return '🟡';
|
|
|
+ return '🟢';
|
|
|
+ },
|
|
|
+ getDeficiencyLabel(record) {
|
|
|
+ if (record.deficiencyType === 'deficiency') {
|
|
|
+ if (record.severity >= 0.7) return '严重缺乏';
|
|
|
+ if (record.severity >= 0.3) return '缺乏';
|
|
|
+ return '轻度不足';
|
|
|
+ }
|
|
|
+ if (record.deficiencyType === 'excess') return '偏高';
|
|
|
+ if (record.deficiencyType === 'borderline') return '边界值';
|
|
|
+ return '未分类';
|
|
|
+ },
|
|
|
+ getSeverityColor(severity) {
|
|
|
+ if (severity >= 0.7) return '#EF4444';
|
|
|
+ if (severity >= 0.3) return '#F59E0B';
|
|
|
+ return '#10B981';
|
|
|
+ },
|
|
|
+ getReportTypeName(type) {
|
|
|
+ const map = { gut_flora: '肠道检测', physical_exam: '体检报告', blood: '血液检测' };
|
|
|
+ return map[type] || type || '健康报告';
|
|
|
+ },
|
|
|
+ formatDate(date) {
|
|
|
+ if (!date) return '';
|
|
|
+ return date.substring(5);
|
|
|
+ },
|
|
|
+ goToReport(id) {
|
|
|
+ uni.navigateTo({ url: `/pages/body/health-report?id=${id}&childId=${this.childId}` });
|
|
|
+ },
|
|
|
+ goToReportList() {
|
|
|
+ uni.navigateTo({ url: `/pages/health/report-list?childId=${this.childId}` });
|
|
|
+ },
|
|
|
+ goToIndicators() {
|
|
|
+ uni.navigateTo({ url: `/pages/body/health-report?childId=${this.childId}&tab=indicators` });
|
|
|
+ },
|
|
|
+ goToAssessment() {
|
|
|
+ uni.navigateTo({ url: `/pages/assessment/apply` });
|
|
|
+ },
|
|
|
+ goToAIRecommend() {
|
|
|
+ uni.navigateTo({ url: `/pages/meal/recommend?childId=${this.childId}` });
|
|
|
+ }
|
|
|
+ }
|
|
|
+};
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.nutrition-profile {
|
|
|
+ min-height: 100vh;
|
|
|
+ background: #FFF7ED;
|
|
|
+ padding-bottom: 40rpx;
|
|
|
+}
|
|
|
+.profile-header {
|
|
|
+ padding: 40rpx 30rpx 20rpx;
|
|
|
+}
|
|
|
+.child-info {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+}
|
|
|
+.child-name {
|
|
|
+ font-size: 40rpx;
|
|
|
+ font-weight: bold;
|
|
|
+ color: #1F2937;
|
|
|
+}
|
|
|
+.child-age {
|
|
|
+ font-size: 26rpx;
|
|
|
+ color: #6B7280;
|
|
|
+ margin-top: 8rpx;
|
|
|
+}
|
|
|
+.score-section {
|
|
|
+ padding: 0 30rpx;
|
|
|
+ background: white;
|
|
|
+ border-radius: 24rpx;
|
|
|
+ margin: 0 20rpx;
|
|
|
+ padding: 30rpx;
|
|
|
+ box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.05);
|
|
|
+}
|
|
|
+.main-score-ring {
|
|
|
+ display: flex;
|
|
|
+ justify-content: center;
|
|
|
+ align-items: center;
|
|
|
+ margin-bottom: 30rpx;
|
|
|
+}
|
|
|
+.main-score-text {
|
|
|
+ position: absolute;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+}
|
|
|
+.score-value {
|
|
|
+ font-size: 56rpx;
|
|
|
+ font-weight: bold;
|
|
|
+ color: #F97316;
|
|
|
+}
|
|
|
+.score-label {
|
|
|
+ font-size: 24rpx;
|
|
|
+ color: #9CA3AF;
|
|
|
+}
|
|
|
+.score-items {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: 1fr 1fr 1fr 1fr;
|
|
|
+ gap: 20rpx;
|
|
|
+}
|
|
|
+.score-item {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+}
|
|
|
+.score-num {
|
|
|
+ font-size: 32rpx;
|
|
|
+ font-weight: bold;
|
|
|
+ color: #374151;
|
|
|
+}
|
|
|
+.score-name {
|
|
|
+ font-size: 22rpx;
|
|
|
+ color: #9CA3AF;
|
|
|
+ margin-top: 4rpx;
|
|
|
+}
|
|
|
+.empty-state {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ padding: 80rpx 30rpx;
|
|
|
+}
|
|
|
+.empty-icon {
|
|
|
+ font-size: 80rpx;
|
|
|
+ margin-bottom: 20rpx;
|
|
|
+}
|
|
|
+.empty-title {
|
|
|
+ font-size: 36rpx;
|
|
|
+ font-weight: bold;
|
|
|
+ color: #374151;
|
|
|
+}
|
|
|
+.empty-desc {
|
|
|
+ font-size: 28rpx;
|
|
|
+ color: #9CA3AF;
|
|
|
+ text-align: center;
|
|
|
+ margin: 16rpx 0 30rpx;
|
|
|
+}
|
|
|
+.empty-btn {
|
|
|
+ background: #F97316;
|
|
|
+ color: white;
|
|
|
+ border-radius: 40rpx;
|
|
|
+ padding: 20rpx 60rpx;
|
|
|
+ font-size: 28rpx;
|
|
|
+}
|
|
|
+.section {
|
|
|
+ background: white;
|
|
|
+ border-radius: 24rpx;
|
|
|
+ margin: 20rpx;
|
|
|
+ padding: 30rpx;
|
|
|
+ box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.05);
|
|
|
+}
|
|
|
+.section-title {
|
|
|
+ font-size: 32rpx;
|
|
|
+ font-weight: bold;
|
|
|
+ color: #374151;
|
|
|
+ margin-bottom: 20rpx;
|
|
|
+}
|
|
|
+.indicator-list {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 16rpx;
|
|
|
+}
|
|
|
+.indicator-item {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+}
|
|
|
+.indicator-left {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 16rpx;
|
|
|
+}
|
|
|
+.indicator-status {
|
|
|
+ font-size: 32rpx;
|
|
|
+}
|
|
|
+.indicator-info {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+}
|
|
|
+.indicator-name {
|
|
|
+ font-size: 28rpx;
|
|
|
+ color: #374151;
|
|
|
+}
|
|
|
+.indicator-value {
|
|
|
+ font-size: 24rpx;
|
|
|
+ color: #9CA3AF;
|
|
|
+ margin-top: 4rpx;
|
|
|
+}
|
|
|
+.section-more {
|
|
|
+ text-align: center;
|
|
|
+ color: #F97316;
|
|
|
+ font-size: 26rpx;
|
|
|
+ margin-top: 20rpx;
|
|
|
+}
|
|
|
+.trend-canvas {
|
|
|
+ width: 100%;
|
|
|
+ height: 300rpx;
|
|
|
+}
|
|
|
+.trend-legend {
|
|
|
+ display: flex;
|
|
|
+ gap: 30rpx;
|
|
|
+ justify-content: center;
|
|
|
+ margin-top: 16rpx;
|
|
|
+}
|
|
|
+.legend-item {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8rpx;
|
|
|
+ font-size: 24rpx;
|
|
|
+ color: #6B7280;
|
|
|
+}
|
|
|
+.legend-color {
|
|
|
+ width: 20rpx;
|
|
|
+ height: 20rpx;
|
|
|
+ border-radius: 4rpx;
|
|
|
+}
|
|
|
+.deficiency-list {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 20rpx;
|
|
|
+}
|
|
|
+.deficiency-item {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 8rpx;
|
|
|
+}
|
|
|
+.deficiency-info {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+}
|
|
|
+.deficiency-name {
|
|
|
+ font-size: 28rpx;
|
|
|
+ color: #374151;
|
|
|
+ font-weight: 500;
|
|
|
+}
|
|
|
+.deficiency-level {
|
|
|
+ font-size: 24rpx;
|
|
|
+ color: #9CA3AF;
|
|
|
+}
|
|
|
+.deficiency-bar {
|
|
|
+ height: 12rpx;
|
|
|
+ background: #F3F4F6;
|
|
|
+ border-radius: 6rpx;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+.deficiency-fill {
|
|
|
+ height: 100%;
|
|
|
+ border-radius: 6rpx;
|
|
|
+}
|
|
|
+.deficiency-foods {
|
|
|
+ font-size: 22rpx;
|
|
|
+ color: #6B7280;
|
|
|
+}
|
|
|
+.nutrition-coverage {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 16rpx;
|
|
|
+}
|
|
|
+.coverage-item {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 16rpx;
|
|
|
+}
|
|
|
+.coverage-label {
|
|
|
+ width: 100rpx;
|
|
|
+ font-size: 26rpx;
|
|
|
+ color: #374151;
|
|
|
+}
|
|
|
+.coverage-bar-wrap {
|
|
|
+ flex: 1;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 12rpx;
|
|
|
+}
|
|
|
+.coverage-bar {
|
|
|
+ flex: 1;
|
|
|
+ height: 16rpx;
|
|
|
+ background: #F3F4F6;
|
|
|
+ border-radius: 8rpx;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+.coverage-fill {
|
|
|
+ height: 100%;
|
|
|
+ background: linear-gradient(90deg, #F97316, #FB923C);
|
|
|
+ border-radius: 8rpx;
|
|
|
+}
|
|
|
+.coverage-percent {
|
|
|
+ width: 80rpx;
|
|
|
+ font-size: 24rpx;
|
|
|
+ color: #6B7280;
|
|
|
+ text-align: right;
|
|
|
+}
|
|
|
+.checkin-total {
|
|
|
+ font-size: 28rpx;
|
|
|
+ color: #6B7280;
|
|
|
+ margin-bottom: 20rpx;
|
|
|
+}
|
|
|
+.checkin-recent {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 12rpx;
|
|
|
+}
|
|
|
+.checkin-day {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 16rpx;
|
|
|
+}
|
|
|
+.day-date {
|
|
|
+ width: 100rpx;
|
|
|
+ font-size: 24rpx;
|
|
|
+ color: #9CA3AF;
|
|
|
+}
|
|
|
+.day-behaviors {
|
|
|
+ display: flex;
|
|
|
+ gap: 8rpx;
|
|
|
+ flex-wrap: wrap;
|
|
|
+}
|
|
|
+.behavior-tag {
|
|
|
+ background: #FFF7ED;
|
|
|
+ color: #F97316;
|
|
|
+ font-size: 22rpx;
|
|
|
+ padding: 4rpx 12rpx;
|
|
|
+ border-radius: 16rpx;
|
|
|
+}
|
|
|
+.report-list {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 16rpx;
|
|
|
+}
|
|
|
+.report-item {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ padding: 16rpx;
|
|
|
+ background: #FFF7ED;
|
|
|
+ border-radius: 16rpx;
|
|
|
+}
|
|
|
+.report-type {
|
|
|
+ font-size: 28rpx;
|
|
|
+ color: #374151;
|
|
|
+}
|
|
|
+.report-date {
|
|
|
+ font-size: 24rpx;
|
|
|
+ color: #9CA3AF;
|
|
|
+ margin-top: 4rpx;
|
|
|
+}
|
|
|
+.report-right {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 12rpx;
|
|
|
+}
|
|
|
+.report-score {
|
|
|
+ font-size: 28rpx;
|
|
|
+ color: #F97316;
|
|
|
+ font-weight: bold;
|
|
|
+}
|
|
|
+.report-arrow {
|
|
|
+ color: #9CA3AF;
|
|
|
+}
|
|
|
+.ai-btn {
|
|
|
+ background: linear-gradient(135deg, #F97316, #FB923C);
|
|
|
+ color: white;
|
|
|
+ border-radius: 40rpx;
|
|
|
+ padding: 24rpx;
|
|
|
+ font-size: 30rpx;
|
|
|
+ text-align: center;
|
|
|
+ border: none;
|
|
|
+}
|
|
|
+</style>
|
|
|
+```
|
|
|
+
|
|
|
+**注意**: 小程序禁止使用可选链 `?.` — 已全部用 `&&` 替代;使用 Vue 2 Options API
|
|
|
+
|
|
|
+- [ ] **Step 3: 在 pages.json 注册新页面**
|
|
|
+
|
|
|
+在 `cfc-frontend/pages.json` 中找到页面数组末尾,在 `pages/body/` 路由之后、`pages/assessment/` 路由之前添加:
|
|
|
+
|
|
|
+```json
|
|
|
+{
|
|
|
+ "path": "pages/health/nutrition-profile",
|
|
|
+ "style": {
|
|
|
+ "navigationBarTitleText": "孩子营养档案",
|
|
|
+ "navigationBarBackgroundColor": "#FFF7ED",
|
|
|
+ "navigationBarTextStyle": "black"
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-frontend/pages/health/nutrition-profile.vue
|
|
|
+git add cfc-frontend/pages.json
|
|
|
+git commit -m "feat(frontend): add nutrition-profile unified view page"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### T10: pages/body/health-report.vue 改造(解读+趋势+雷达+矩阵)
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-frontend/pages/body/health-report.vue`
|
|
|
+
|
|
|
+**参考文件**: 现有 `health-report.vue` 结构,了解现有环形分数和指标表格的位置
|
|
|
+
|
|
|
+**依赖**: T6 (API端点) + T12 (api.js新增API)
|
|
|
+
|
|
|
+- [ ] **Step 1: 阅读现有 health-report.vue 确认结构**
|
|
|
+
|
|
|
+找到以下区域:1. 评分圆环区域 2. 指标表格区域 3. 页面底部
|
|
|
+
|
|
|
+- [ ] **Step 2: 在评分圆环区域下方增加通俗解读卡片**
|
|
|
+
|
|
|
+在指标表格区域上方(现有结构)添加:
|
|
|
+
|
|
|
+```vue
|
|
|
+<!-- 通俗解读卡片 -->
|
|
|
+<view class="interpretation-section" v-if="deficiencyRecords && deficiencyRecords.length > 0">
|
|
|
+ <view class="section-title">💡 这意味着什么?</view>
|
|
|
+ <view class="interpretation-list">
|
|
|
+ <view class="interpretation-item" v-for="record in deficiencyRecords" :key="record.nutrient">
|
|
|
+ <view class="interp-header">
|
|
|
+ <text class="interp-emoji">{{ getInterpretEmoji(record) }}</text>
|
|
|
+ <text class="interp-nutrient">{{ record.nutrient }}</text>
|
|
|
+ <text class="interp-status">{{ record.deficiencyType === 'deficiency' ? '偏低' : record.deficiencyType === 'excess' ? '偏高' : '注意' }}</text>
|
|
|
+ </view>
|
|
|
+ <text class="interp-symptoms">{{ record.symptoms || '暂无详细症状描述' }}</text>
|
|
|
+ <text class="interp-food" v-if="record.foodSuggestions">🍽️ {{ record.foodSuggestions }}</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+</view>
|
|
|
+```
|
|
|
+
|
|
|
+对应的样式(添加在 `<style scoped>` 中):
|
|
|
+
|
|
|
+```css
|
|
|
+.interpretation-section {
|
|
|
+ background: white;
|
|
|
+ border-radius: 24rpx;
|
|
|
+ margin: 20rpx;
|
|
|
+ padding: 30rpx;
|
|
|
+}
|
|
|
+.interpretation-list {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 20rpx;
|
|
|
+ margin-top: 16rpx;
|
|
|
+}
|
|
|
+.interpretation-item {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 8rpx;
|
|
|
+ padding: 20rpx;
|
|
|
+ background: #FFF7ED;
|
|
|
+ border-radius: 16rpx;
|
|
|
+}
|
|
|
+.interp-header {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 12rpx;
|
|
|
+}
|
|
|
+.interp-emoji {
|
|
|
+ font-size: 32rpx;
|
|
|
+}
|
|
|
+.interp-nutrient {
|
|
|
+ font-size: 30rpx;
|
|
|
+ font-weight: bold;
|
|
|
+ color: #374151;
|
|
|
+}
|
|
|
+.interp-status {
|
|
|
+ font-size: 24rpx;
|
|
|
+ color: #9CA3AF;
|
|
|
+}
|
|
|
+.interp-symptoms {
|
|
|
+ font-size: 26rpx;
|
|
|
+ color: #6B7280;
|
|
|
+ line-height: 1.5;
|
|
|
+}
|
|
|
+.interp-food {
|
|
|
+ font-size: 24rpx;
|
|
|
+ color: #F97316;
|
|
|
+ margin-top: 8rpx;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 在评分区域增加趋势图**
|
|
|
+
|
|
|
+在综合评分圆环下方(圆环 section 内)添加:
|
|
|
+
|
|
|
+```vue
|
|
|
+<!-- 趋势对比(单份报告时不显示) -->
|
|
|
+<view class="trend-section" v-if="scoreTrend && scoreTrend.length > 1">
|
|
|
+ <view class="trend-title">📈 健康评分变化</view>
|
|
|
+ <view class="trend-chart-wrap">
|
|
|
+ <canvas canvas-id="scoreTrendCanvas" id="scoreTrendCanvas" class="score-trend-canvas"></canvas>
|
|
|
+ </view>
|
|
|
+ <view class="trend-summary">
|
|
|
+ <text v-for="(item, idx) in scoreTrend" :key="idx" class="trend-point">
|
|
|
+ {{ formatTrendDate(item.reportDate) }}: {{ item.overall }}分
|
|
|
+ <text v-if="idx < scoreTrend.length - 1">{{ item.overall < scoreTrend[idx+1].overall ? '↑' : item.overall > scoreTrend[idx+1].overall ? '↓' : '→' }}</text>
|
|
|
+ </text>
|
|
|
+ </view>
|
|
|
+</view>
|
|
|
+```
|
|
|
+
|
|
|
+样式:
|
|
|
+
|
|
|
+```css
|
|
|
+.trend-section {
|
|
|
+ padding: 20rpx 0 0;
|
|
|
+ border-top: 1rpx solid #F3F4F6;
|
|
|
+ margin-top: 20rpx;
|
|
|
+}
|
|
|
+.trend-title {
|
|
|
+ font-size: 28rpx;
|
|
|
+ font-weight: bold;
|
|
|
+ color: #374151;
|
|
|
+ margin-bottom: 16rpx;
|
|
|
+}
|
|
|
+.score-trend-canvas {
|
|
|
+ width: 100%;
|
|
|
+ height: 250rpx;
|
|
|
+}
|
|
|
+.trend-summary {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-around;
|
|
|
+ margin-top: 16rpx;
|
|
|
+}
|
|
|
+.trend-point {
|
|
|
+ font-size: 24rpx;
|
|
|
+ color: #6B7280;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 在页面底部增加营养缺乏矩阵卡片**
|
|
|
+
|
|
|
+在页面底部(`</view>` 结束前)添加:
|
|
|
+
|
|
|
+```vue
|
|
|
+<!-- 营养缺乏矩阵 -->
|
|
|
+<view class="deficiency-matrix-section" v-if="deficiencyRecords && deficiencyRecords.length > 0">
|
|
|
+ <view class="section-title">🔬 营养缺乏分析</view>
|
|
|
+ <view class="matrix-table">
|
|
|
+ <view class="matrix-header">
|
|
|
+ <text class="matrix-col-nutrient">营养素</text>
|
|
|
+ <text class="matrix-col-level">状态</text>
|
|
|
+ <text class="matrix-col-bar">缺乏程度</text>
|
|
|
+ </view>
|
|
|
+ <view class="matrix-row" v-for="record in deficiencyRecords" :key="record.nutrient">
|
|
|
+ <text class="matrix-nutrient">{{ record.nutrient }}</text>
|
|
|
+ <text class="matrix-level">{{ getDeficiencyLevelLabel(record) }}</text>
|
|
|
+ <view class="matrix-bar-wrap">
|
|
|
+ <view class="matrix-bar">
|
|
|
+ <view class="matrix-fill" :style="{ width: (record.severity * 100) + '%', background: getSeverityBarColor(record.severity) }"></view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ <view class="matrix-priority" v-if="deficiencyRecords.length > 0">
|
|
|
+ <text class="priority-title">🎯 营养改善优先级:</text>
|
|
|
+ <view v-for="(record, idx) in deficiencyRecords.slice(0, 3)" :key="record.nutrient">
|
|
|
+ <text class="priority-item">{{ idx + 1 }}. {{ record.nutrient }} → {{ record.foodSuggestions }}</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+</view>
|
|
|
+```
|
|
|
+
|
|
|
+样式:
|
|
|
+
|
|
|
+```css
|
|
|
+.deficiency-matrix-section {
|
|
|
+ background: white;
|
|
|
+ border-radius: 24rpx;
|
|
|
+ margin: 20rpx;
|
|
|
+ padding: 30rpx;
|
|
|
+}
|
|
|
+.matrix-table {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 12rpx;
|
|
|
+ margin: 16rpx 0;
|
|
|
+}
|
|
|
+.matrix-header, .matrix-row {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: 100rpx 120rpx 1fr;
|
|
|
+ align-items: center;
|
|
|
+ gap: 16rpx;
|
|
|
+}
|
|
|
+.matrix-header {
|
|
|
+ font-size: 24rpx;
|
|
|
+ color: #9CA3AF;
|
|
|
+ padding-bottom: 8rpx;
|
|
|
+ border-bottom: 1rpx solid #F3F4F6;
|
|
|
+}
|
|
|
+.matrix-nutrient {
|
|
|
+ font-size: 26rpx;
|
|
|
+ color: #374151;
|
|
|
+ font-weight: 500;
|
|
|
+}
|
|
|
+.matrix-level {
|
|
|
+ font-size: 22rpx;
|
|
|
+ color: #6B7280;
|
|
|
+}
|
|
|
+.matrix-bar-wrap {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+}
|
|
|
+.matrix-bar {
|
|
|
+ flex: 1;
|
|
|
+ height: 12rpx;
|
|
|
+ background: #F3F4F6;
|
|
|
+ border-radius: 6rpx;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+.matrix-fill {
|
|
|
+ height: 100%;
|
|
|
+ border-radius: 6rpx;
|
|
|
+}
|
|
|
+.matrix-priority {
|
|
|
+ margin-top: 20rpx;
|
|
|
+ padding-top: 16rpx;
|
|
|
+ border-top: 1rpx solid #F3F4F6;
|
|
|
+}
|
|
|
+.priority-title {
|
|
|
+ font-size: 26rpx;
|
|
|
+ font-weight: bold;
|
|
|
+ color: #374151;
|
|
|
+ display: block;
|
|
|
+ margin-bottom: 8rpx;
|
|
|
+}
|
|
|
+.priority-item {
|
|
|
+ font-size: 24rpx;
|
|
|
+ color: #6B7280;
|
|
|
+ display: block;
|
|
|
+ line-height: 1.6;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: 在 data() 中新增响应式字段**
|
|
|
+
|
|
|
+```javascript
|
|
|
+data() {
|
|
|
+ return {
|
|
|
+ // ... existing fields ...
|
|
|
+ deficiencyRecords: [],
|
|
|
+ scoreTrend: []
|
|
|
+ };
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 6: 在 onLoad / onShow 中加载数据**
|
|
|
+
|
|
|
+```javascript
|
|
|
+onLoad(options) {
|
|
|
+ this.childId = options.childId || 1;
|
|
|
+ // ... existing load logic ...
|
|
|
+
|
|
|
+ // 加载缺乏分析数据
|
|
|
+ this.loadDeficiencyData();
|
|
|
+
|
|
|
+ // 加载评分趋势(从报告列表)
|
|
|
+ this.loadScoreTrend();
|
|
|
+},
|
|
|
+
|
|
|
+methods: {
|
|
|
+ loadDeficiencyData() {
|
|
|
+ this.$api.getDeficiencyAnalysis(this.childId).then(res => {
|
|
|
+ if (res.code === 200 && res.data) {
|
|
|
+ this.deficiencyRecords = res.data;
|
|
|
+ }
|
|
|
+ });
|
|
|
+ },
|
|
|
+
|
|
|
+ loadScoreTrend() {
|
|
|
+ // 通过报告列表接口获取趋势
|
|
|
+ this.$api.getReportList(this.childId).then(res => {
|
|
|
+ if (res.code === 200 && res.data) {
|
|
|
+ // 取最近3份报告
|
|
|
+ this.scoreTrend = (res.data.records || []).slice(0, 3).map(r => ({
|
|
|
+ reportDate: r.reportDate,
|
|
|
+ overall: r.overallScore,
|
|
|
+ gutHealth: r.gutHealthScore,
|
|
|
+ nutrition: r.nutritionScore
|
|
|
+ }));
|
|
|
+ }
|
|
|
+ });
|
|
|
+ },
|
|
|
+
|
|
|
+ getInterpretEmoji(record) {
|
|
|
+ if (record.deficiencyType === 'deficiency') {
|
|
|
+ if (record.severity >= 0.7) return '🔴';
|
|
|
+ return '🟡';
|
|
|
+ }
|
|
|
+ return '🟢';
|
|
|
+ },
|
|
|
+
|
|
|
+ getDeficiencyLevelLabel(record) {
|
|
|
+ if (record.deficiencyType === 'deficiency') {
|
|
|
+ if (record.severity >= 0.7) return '严重缺乏';
|
|
|
+ if (record.severity >= 0.3) return '缺乏';
|
|
|
+ return '轻度不足';
|
|
|
+ }
|
|
|
+ if (record.deficiencyType === 'excess') return '偏高';
|
|
|
+ if (record.deficiencyType === 'borderline') return '边界值';
|
|
|
+ return '未分类';
|
|
|
+ },
|
|
|
+
|
|
|
+ getSeverityBarColor(severity) {
|
|
|
+ if (severity >= 0.7) return '#EF4444';
|
|
|
+ if (severity >= 0.3) return '#F59E0B';
|
|
|
+ return '#10B981';
|
|
|
+ },
|
|
|
+
|
|
|
+ formatTrendDate(date) {
|
|
|
+ if (!date) return '';
|
|
|
+ return date.substring(5, 10);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 7: 编译验证**
|
|
|
+
|
|
|
+启动小程序 `npm run dev:mp-weixin` 无报错即可
|
|
|
+
|
|
|
+- [ ] **Step 8: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-frontend/pages/body/health-report.vue
|
|
|
+git commit -m "feat(frontend): add interpretation cards, trend charts, deficiency matrix to health-report"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### T11: pages/body/index.vue 导航入口
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-frontend/pages/body/index.vue`
|
|
|
+
|
|
|
+**参考文件**: `body/index.vue` — 找到健康报告卡片的点击处理
|
|
|
+
|
|
|
+**依赖**: T9 (nutrition-profile页面已创建)
|
|
|
+
|
|
|
+- [ ] **Step 1: 阅读 body/index.vue 找到健康报告相关的卡片区域**
|
|
|
+
|
|
|
+找到 `navigateTo` 或 `goTo` 相关的方法
|
|
|
+
|
|
|
+- [ ] **Step 2: 修改健康报告卡片的点击跳转逻辑**
|
|
|
+
|
|
|
+在相关卡片的 `@click` 处理方法中,添加跳转到 nutrition-profile:
|
|
|
+
|
|
|
+```javascript
|
|
|
+// 找到跳转到健康报告的方法,修改为:
|
|
|
+goToNutritionProfile() {
|
|
|
+ // 获取当前孩子的 childId(从全局状态或参数)
|
|
|
+ const childId = this.$store.getters.currentChildId || 1;
|
|
|
+ uni.navigateTo({
|
|
|
+ url: `/pages/health/nutrition-profile?childId=${childId}`
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+// 或在现有跳转到 health-report 的方法中增加链接:
|
|
|
+goToHealthReport() {
|
|
|
+ const childId = this.$store.getters.currentChildId || 1;
|
|
|
+ uni.navigateTo({
|
|
|
+ url: `/pages/body/health-report?childId=${childId}`
|
|
|
+ });
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+在模板中将健康报告卡片的点击区域从跳转 health-report 改为跳转 nutrition-profile(或者新增一个"查看档案"按钮):
|
|
|
+
|
|
|
+```vue
|
|
|
+<!-- 在健康报告卡片底部添加 "查看完整档案" 按钮 -->
|
|
|
+<view class="card-action" @click="goToNutritionProfile">
|
|
|
+ <text>查看完整档案 →</text>
|
|
|
+</view>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-frontend/pages/body/index.vue
|
|
|
+git commit -m "feat(frontend): add nutrition-profile entry in body index page"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### T12: utils/api.js 新增4个API函数
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-frontend/utils/api.js`
|
|
|
+
|
|
|
+**参考文件**: `api.js` — 确认现有 `request()` 封装方式(L1280-1311 健康相关API模式)
|
|
|
+
|
|
|
+**依赖**: T6 (后端4个端点已创建)
|
|
|
+
|
|
|
+- [ ] **Step 1: 阅读 api.js 确认 request() 用法和现有健康API位置**
|
|
|
+
|
|
|
+文件路径:`cfc-frontend/utils/api.js`
|
|
|
+
|
|
|
+找到 `getLatestHealthReport` 或 `getHealthIndicatorList` 等函数位置
|
|
|
+
|
|
|
+- [ ] **Step 2: 在健康相关API区域(大约L1303-1311)添加4个新函数**
|
|
|
+
|
|
|
+在 `export const` 区域添加:
|
|
|
+
|
|
|
+```javascript
|
|
|
+/**
|
|
|
+ * 孩子营养档案聚合
|
|
|
+ */
|
|
|
+export const getNutritionProfile = (childId) => {
|
|
|
+ return request('/api/health/nutrition-profile', 'POST', { childId });
|
|
|
+};
|
|
|
+
|
|
|
+/**
|
|
|
+ * 获取孩子最新缺乏分析数据
|
|
|
+ */
|
|
|
+export const getDeficiencyAnalysis = (childId) => {
|
|
|
+ return request('/api/health/deficiency/latest', 'POST', { childId });
|
|
|
+};
|
|
|
+
|
|
|
+/**
|
|
|
+ * 获取孩子缺乏趋势
|
|
|
+ */
|
|
|
+export const getDeficiencyTrend = (childId) => {
|
|
|
+ return request('/api/health/deficiency/trend', 'POST', { childId });
|
|
|
+};
|
|
|
+
|
|
|
+/**
|
|
|
+ * 触发营养缺乏分析
|
|
|
+ */
|
|
|
+export const analyzeDeficiency = (reportId, childId) => {
|
|
|
+ return request('/api/health/deficiency/analyze', 'POST', { reportId, childId });
|
|
|
+};
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 确认全局 api 对象已包含新函数**
|
|
|
+
|
|
|
+在文件底部确认 api 对象 export 包含新函数
|
|
|
+
|
|
|
+- [ ] **Step 4: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-frontend/utils/api.js
|
|
|
+git commit -m "feat(api): add 4 health deficiency and profile API functions"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Wave 5: Web管理端
|
|
|
+
|
|
|
+### T13: nutritionist/NutritionMappings.vue 新页面
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-web/src/views/nutritionist/NutritionMappings.vue`
|
|
|
+
|
|
|
+**参考文件**: `cfc-web/src/views/nutritionist/HealthReports.vue` (现有表格CRUD结构)
|
|
|
+
|
|
|
+**依赖**: T1 (表已建) + T2 (Entity+Mapper已创建)
|
|
|
+
|
|
|
+- [ ] **Step 1: 阅读 HealthReports.vue 确认 Element UI 表格用法**
|
|
|
+
|
|
|
+了解 `el-table`、`el-button`、`el-dialog` 的使用模式
|
|
|
+
|
|
|
+- [ ] **Step 2: 创建 NutritionMappings.vue — 映射表CRUD页面**
|
|
|
+
|
|
|
+文件路径:`cfc-web/src/views/nutritionist/NutritionMappings.vue`
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <div class="nutrition-mappings">
|
|
|
+ <div class="page-header">
|
|
|
+ <h2>营养素映射管理</h2>
|
|
|
+ <el-button type="primary" @click="openCreateDialog">新增映射</el-button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 映射列表 -->
|
|
|
+ <el-table :data="mappings" border style="width: 100%">
|
|
|
+ <el-table-column prop="id" label="ID" width="60" />
|
|
|
+ <el-table-column prop="indicatorNamePattern" label="指标名模式" width="180" />
|
|
|
+ <el-table-column prop="nutrient" label="营养素" width="100" />
|
|
|
+ <el-table-column prop="severityWeight" label="权重" width="80">
|
|
|
+ <template slot-scope="scope">
|
|
|
+ {{ scope.row.severityWeight }}
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column prop="deficiencySymptoms" label="缺乏症状" min-width="150" show-overflow-tooltip />
|
|
|
+ <el-table-column prop="foodSuggestions" label="推荐食物" min-width="180" show-overflow-tooltip />
|
|
|
+ <el-table-column prop="sortOrder" label="排序" width="80" />
|
|
|
+ <el-table-column label="操作" width="150" fixed="right">
|
|
|
+ <template slot-scope="scope">
|
|
|
+ <el-button size="mini" @click="openEditDialog(scope.row)">编辑</el-button>
|
|
|
+ <el-button size="mini" type="danger" @click="handleDelete(scope.row.id)">删除</el-button>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ </el-table>
|
|
|
+
|
|
|
+ <!-- 新增/编辑对话框 -->
|
|
|
+ <el-dialog :title="dialogTitle" :visible.sync="dialogVisible" width="600px">
|
|
|
+ <el-form :model="form" label-width="120px">
|
|
|
+ <el-form-item label="指标名模式">
|
|
|
+ <el-input v-model="form.indicatorNamePattern" placeholder="如: 铁蛋白, 维生素D%" />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="营养素">
|
|
|
+ <el-input v-model="form.nutrient" placeholder="如: 铁, 维生素D" />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="权重(0-1)">
|
|
|
+ <el-input-number v-model="form.severityWeight" :min="0" :max="1" :step="0.1" />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="缺乏症状">
|
|
|
+ <el-input v-model="form.deficiencySymptoms" type="textarea" :rows="2" />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="过量症状">
|
|
|
+ <el-input v-model="form.excessSymptoms" type="textarea" :rows="2" />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="推荐食物">
|
|
|
+ <el-input v-model="form.foodSuggestions" placeholder="逗号分隔,如: 动物肝脏,红肉,菠菜" />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="排序">
|
|
|
+ <el-input-number v-model="form.sortOrder" :min="0" />
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
+ <span slot="footer">
|
|
|
+ <el-button @click="dialogVisible = false">取消</el-button>
|
|
|
+ <el-button type="primary" @click="handleSave">保存</el-button>
|
|
|
+ </span>
|
|
|
+ </el-dialog>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+export default {
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ mappings: [],
|
|
|
+ dialogVisible: false,
|
|
|
+ dialogTitle: '新增映射',
|
|
|
+ form: {
|
|
|
+ id: null,
|
|
|
+ indicatorNamePattern: '',
|
|
|
+ nutrient: '',
|
|
|
+ severityWeight: 1.0,
|
|
|
+ deficiencySymptoms: '',
|
|
|
+ excessSymptoms: '',
|
|
|
+ foodSuggestions: '',
|
|
|
+ sortOrder: 10
|
|
|
+ }
|
|
|
+ };
|
|
|
+ },
|
|
|
+ created() {
|
|
|
+ this.loadMappings();
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ loadMappings() {
|
|
|
+ this.$http.get('/api/health/nutrition-mapping/list').then(res => {
|
|
|
+ this.mappings = res.data || [];
|
|
|
+ });
|
|
|
+ },
|
|
|
+ openCreateDialog() {
|
|
|
+ this.form = { id: null, indicatorNamePattern: '', nutrient: '', severityWeight: 1.0, deficiencySymptoms: '', excessSymptoms: '', foodSuggestions: '', sortOrder: 10 };
|
|
|
+ this.dialogTitle = '新增映射';
|
|
|
+ this.dialogVisible = true;
|
|
|
+ },
|
|
|
+ openEditDialog(row) {
|
|
|
+ this.form = { ...row };
|
|
|
+ this.dialogTitle = '编辑映射';
|
|
|
+ this.dialogVisible = true;
|
|
|
+ },
|
|
|
+ handleSave() {
|
|
|
+ const method = this.form.id ? 'put' : 'post';
|
|
|
+ const url = this.form.id ? '/api/health/nutrition-mapping/update' : '/api/health/nutrition-mapping/create';
|
|
|
+ this.$http[method](url, this.form).then(() => {
|
|
|
+ this.dialogVisible = false;
|
|
|
+ this.loadMappings();
|
|
|
+ this.$message.success('保存成功');
|
|
|
+ });
|
|
|
+ },
|
|
|
+ handleDelete(id) {
|
|
|
+ this.$confirm('确认删除该映射?', '提示').then(() => {
|
|
|
+ this.$http.delete('/api/health/nutrition-mapping/delete?id=' + id).then(() => {
|
|
|
+ this.loadMappings();
|
|
|
+ this.$message.success('删除成功');
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+};
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.nutrition-mappings {
|
|
|
+ padding: 20px;
|
|
|
+}
|
|
|
+.page-header {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ margin-bottom: 20px;
|
|
|
+}
|
|
|
+.page-header h2 {
|
|
|
+ margin: 0;
|
|
|
+ font-size: 20px;
|
|
|
+}
|
|
|
+</style>
|
|
|
+```
|
|
|
+
|
|
|
+**注意**: 需要后端提供 `/api/health/nutrition-mapping/list`、`create`、`update`、`delete` 四个端点(简单的CRUD)。如对应的Controller不存在,需先创建 `NutritionMappingController.java`
|
|
|
+
|
|
|
+- [ ] **Step 3: 如 NutritionMappingController 不存在,创建基础CRUD**
|
|
|
+
|
|
|
+文件路径:`cfc-backend/src/main/java/com/etotem/cfc/controller/NutritionMappingController.java`
|
|
|
+
|
|
|
+```java
|
|
|
+@PostMapping("/nutrition-mapping/list")
|
|
|
+public Result<List<NutritionIndicatorMapping>> list() {
|
|
|
+ List<NutritionIndicatorMapping> list = mappingMapper.findAllOrderBySort();
|
|
|
+ return Result.success(list);
|
|
|
+}
|
|
|
+
|
|
|
+@PostMapping("/nutrition-mapping/create")
|
|
|
+public Result<?> create(@RequestBody NutritionIndicatorMapping mapping) {
|
|
|
+ mappingMapper.insert(mapping);
|
|
|
+ return Result.success(null);
|
|
|
+}
|
|
|
+
|
|
|
+@PostMapping("/nutrition-mapping/update")
|
|
|
+public Result<?> update(@RequestBody NutritionIndicatorMapping mapping) {
|
|
|
+ mappingMapper.updateById(mapping);
|
|
|
+ return Result.success(null);
|
|
|
+}
|
|
|
+
|
|
|
+@PostMapping("/nutrition-mapping/delete")
|
|
|
+public Result<?> delete(@RequestParam Long id) {
|
|
|
+ mappingMapper.deleteById(id);
|
|
|
+ return Result.success(null);
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 在 Web Admin 路由注册新页面**
|
|
|
+
|
|
|
+文件:`cfc-web/src/router/index.js` — 在 nutritionist 路由下添加:
|
|
|
+
|
|
|
+```javascript
|
|
|
+{
|
|
|
+ path: '/nutritionist/nutrition-mappings',
|
|
|
+ component: () => import('@/views/nutritionist/NutritionMappings'),
|
|
|
+ meta: { title: '营养素映射管理' }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-web/src/views/nutritionist/NutritionMappings.vue
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/NutritionMappingController.java
|
|
|
+git add cfc-web/src/router/index.js
|
|
|
+git commit -m "feat(web): add NutritionMappings CRUD page and controller"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### T14: nutritionist/HealthReports.vue 改造
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-web/src/views/nutritionist/HealthReports.vue`
|
|
|
+
|
|
|
+**依赖**: T13 (NutritionMappings.vue)
|
|
|
+
|
|
|
+- [ ] **Step 1: 阅读 HealthReports.vue 找到现有表格结构**
|
|
|
+
|
|
|
+文件路径:`cfc-web/src/views/nutritionist/HealthReports.vue`
|
|
|
+
|
|
|
+找到报告列表表格区域
|
|
|
+
|
|
|
+- [ ] **Step 2: 在每行报告增加"营养缺乏分析"列**
|
|
|
+
|
|
|
+在表格中添加新列:
|
|
|
+
|
|
|
+```vue
|
|
|
+<el-table-column label="营养缺乏" width="120" fixed="right">
|
|
|
+ <template slot-scope="scope">
|
|
|
+ <el-tag v-if="scope.row.deficiencyCount > 0" type="danger" size="small">
|
|
|
+ {{ scope.row.deficiencyCount }}项缺乏
|
|
|
+ </el-tag>
|
|
|
+ <el-tag v-else type="success" size="small">无缺乏</el-tag>
|
|
|
+ <el-button size="mini" type="text" @click="triggerAnalysis(scope.row)" style="margin-left: 8px">
|
|
|
+ {{ scope.row.deficiencyCount > 0 ? '重新分析' : '分析' }}
|
|
|
+ </el-button>
|
|
|
+ </template>
|
|
|
+</el-table-column>
|
|
|
+```
|
|
|
+
|
|
|
+在 `data()` 中 `report` 对象结构确认有 `deficiencyCount` 字段(如后端未返回则手动调用 API 获取)
|
|
|
+
|
|
|
+- [ ] **Step 3: 添加 triggerAnalysis 方法**
|
|
|
+
|
|
|
+```javascript
|
|
|
+triggerAnalysis(row) {
|
|
|
+ this.$http.post('/api/health/deficiency/analyze', {
|
|
|
+ reportId: row.id,
|
|
|
+ childId: row.childId
|
|
|
+ }).then(res => {
|
|
|
+ this.$message.success('分析完成');
|
|
|
+ this.loadReports(); // 刷新列表
|
|
|
+ });
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-web/src/views/nutritionist/HealthReports.vue
|
|
|
+git commit -m "feat(web): add deficiency analysis trigger to HealthReports"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### T15: nutritionist/HealthIndicators.vue 改造
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-web/src/views/nutritionist/HealthIndicators.vue`
|
|
|
+
|
|
|
+**依赖**: T13 (NutritionMappings.vue)
|
|
|
+
|
|
|
+- [ ] **Step 1: 阅读 HealthIndicators.vue 找到指标表格**
|
|
|
+
|
|
|
+找到指标列表表格区域,确认 `status` 列或 `indicatorName` 列位置
|
|
|
+
|
|
|
+- [ ] **Step 2: 在 status 列后增加"关联营养素"标签列**
|
|
|
+
|
|
|
+在表格中添加新列:
|
|
|
+
|
|
|
+```vue
|
|
|
+<el-table-column label="关联营养素" width="120">
|
|
|
+ <template slot-scope="scope">
|
|
|
+ <template v-if="scope.row.linkedNutrients && scope.row.linkedNutrients.length > 0">
|
|
|
+ <el-tag v-for="nutrient in scope.row.linkedNutrients" :key="nutrient" size="mini" style="margin-right: 4px">
|
|
|
+ {{ nutrient }}
|
|
|
+ </el-tag>
|
|
|
+ </template>
|
|
|
+ <span v-else style="color: #999; font-size: 12px">—</span>
|
|
|
+ </template>
|
|
|
+</el-table-column>
|
|
|
+```
|
|
|
+
|
|
|
+后端需在返回指标时,查询 `nutrition_indicator_mapping` 表做 LIKE 匹配,附加 `linkedNutrients` 字段
|
|
|
+
|
|
|
+- [ ] **Step 3: 异常指标高亮**
|
|
|
+
|
|
|
+在 `row-class-name` 或 `cell-style` 中根据 `status` 返回高亮样式:
|
|
|
+
|
|
|
+```javascript
|
|
|
+cellStyle({ row, column, rowIndex, columnIndex }) {
|
|
|
+ if (columnIndex === 1 && row.status && row.status !== '正常') {
|
|
|
+ return 'background: #FEF2F2'; // 红色背景
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-web/src/views/nutritionist/HealthIndicators.vue
|
|
|
+git commit -m "feat(web): add nutrient linkage tags and highlight abnormal indicators"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 最终验证Wave
|
|
|
+
|
|
|
+### F1: Plan Compliance Audit
|
|
|
+
|
|
|
+读取设计文档 `docs/superpowers/specs/2026-06-23-P0营养师核心功能设计方案.md`,逐项验证:
|
|
|
+- [ ] Feature 1 (统一视图): 页面 + API + 空状态 + 入口导航 → 全部实现
|
|
|
+- [ ] Feature 2 (结构化缺乏分析): 映射表 + 分析服务 + 4个端点 + 集成 → 全部实现
|
|
|
+- [ ] Feature 3 (可视化解读): 通俗解读 + 趋势 + 雷达 + 矩阵 → 全部实现
|
|
|
+- [ ] 所有 Guardrails: 不含微量营养素Food列、不含PDF导出、不含WHO标准 → 验证无越界
|
|
|
+- [ ] 微信小程序约束: 无 `?.` 可选链 → 全部用 `&&` 替代
|
|
|
+
|
|
|
+### F2: Code Quality Review
|
|
|
+
|
|
|
+- [ ] `mvn clean compile` → 通过(0 error)
|
|
|
+- [ ] 检查所有新增 Service/Controller 无 `as any` 滥用(Spring Boot项目,重点检查 `@Resource` 注解是否正确)
|
|
|
+- [ ] 检查新增前端文件无 `console.log` 残留
|
|
|
+- [ ] 检查所有新增文件 import 完整
|
|
|
+
|
|
|
+### F3: Real Manual QA
|
|
|
+
|
|
|
+使用 Postman 或前端页面逐个端点验证:
|
|
|
+
|
|
|
+```bash
|
|
|
+# T6 端点验证
|
|
|
+curl -X POST http://localhost:8080/api/health/nutrition-profile \
|
|
|
+ -H "Content-Type: application/json" \
|
|
|
+ -H "Authorization: Bearer <token>" \
|
|
|
+ -d '{"childId": 1}'
|
|
|
+# 预期: 200 + 聚合数据
|
|
|
+
|
|
|
+curl -X POST http://localhost:8080/api/health/deficiency/latest \
|
|
|
+ -H "Content-Type: application/json" \
|
|
|
+ -H "Authorization: Bearer <token>" \
|
|
|
+ -d '{"childId": 1}'
|
|
|
+# 预期: 200 + 缺乏分析记录列表
|
|
|
+
|
|
|
+curl -X POST http://localhost:8080/api/health/deficiency/trend \
|
|
|
+ -H "Content-Type: application/json" \
|
|
|
+ -H "Authorization: Bearer <token>" \
|
|
|
+ -d '{"childId": 1}'
|
|
|
+# 预期: 200 + 趋势数据
|
|
|
+
|
|
|
+curl -X POST http://localhost:8080/api/health/deficiency/analyze \
|
|
|
+ -H "Content-Type: application/json" \
|
|
|
+ -H "Authorization: Bearer <token>" \
|
|
|
+ -d '{"reportId": 1, "childId": 1}'
|
|
|
+# 预期: 200 + 分析结果列表
|
|
|
+```
|
|
|
+
|
|
|
+### F4: Scope Fidelity Check
|
|
|
+
|
|
|
+- [ ] 无新增表/字段超出设计范围
|
|
|
+- [ ] 无修改现有核心业务逻辑(只扩展,不修改)
|
|
|
+- [ ] commit 历史清晰(每个 task 一个原子 commit)
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 提交策略
|
|
|
+
|
|
|
+每个 Task 独立 commit,总共约 15 个 commit。
|
|
|
+
|
|
|
+```bash
|
|
|
+# Wave 1: DB基础
|
|
|
+git add ... && git commit -m "feat(health): add nutrition tables DDL and seed data"
|
|
|
+git add ... && git commit -m "feat(health): add NutritionIndicatorMapping entity and mapper"
|
|
|
+git add ... && git commit -m "feat(health): add NutritionDeficiencyRecord entity and mapper"
|
|
|
+
|
|
|
+# Wave 2: 服务层
|
|
|
+git add ... && git commit -m "feat(health): implement NutritionDeficiencyService"
|
|
|
+git add ... && git commit -m "feat(health): implement ChildNutritionProfileService"
|
|
|
+
|
|
|
+# Wave 3: API层
|
|
|
+git add ... && git commit -m "feat(api): add 4 health deficiency and profile endpoints"
|
|
|
+git add ... && git commit -m "feat(health): chain NutritionDeficiencyService in HealthAnalysisService"
|
|
|
+git add ... && git commit -m "feat(meal): add deficiency_matrix to Dify inputs"
|
|
|
+
|
|
|
+# Wave 4: 前端
|
|
|
+git add ... && git commit -m "feat(frontend): add nutrition-profile unified view page"
|
|
|
+git add ... && git commit -m "feat(frontend): add interpretation, trend, matrix to health-report"
|
|
|
+git add ... && git commit -m "feat(frontend): add nutrition-profile entry in index"
|
|
|
+git add ... && git commit -m "feat(api): add 4 health deficiency and profile API functions"
|
|
|
+
|
|
|
+# Wave 5: Web Admin
|
|
|
+git add ... && git commit -m "feat(web): add NutritionMappings CRUD page and controller"
|
|
|
+git add ... && git commit -m "feat(web): add deficiency trigger to HealthReports"
|
|
|
+git add ... && git commit -m "feat(web): add nutrient linkage to HealthIndicators"
|
|
|
+
|
|
|
+# 最终
|
|
|
+git push origin cfclub
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 成功标准
|
|
|
+
|
|
|
+- [ ] 15个Tasks全部完成
|
|
|
+- [ ] `mvn clean compile` 通过
|
|
|
+- [ ] 小程序编译无错误
|
|
|
+- [ ] 所有4个新API端点可用(验证返回数据格式正确)
|
|
|
+- [ ] nutrition-profile 页面可正常打开(空状态+有数据状态)
|
|
|
+- [ ] health-report 页面可见化解读正常展示
|
|
|
+- [ ] Web 管理端 NutritionMappings 页面可 CRUD 映射规则
|
|
|
+- [ ] 推送到远程 `origin/cfclub`
|