|
|
@@ -0,0 +1,1091 @@
|
|
|
+# 「当前状态调研」健康现状档案 实现计划
|
|
|
+
|
|
|
+> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
|
|
|
+
|
|
|
+**目标:** 新增 `health_status` 表与档案页(按 userId 一份),出方案流程(`health-plan-summary.vue`)读取健康现状并在未填时弹窗提醒,健康现状+饮食偏好随 AI 请求注入 Dify inputs,提升方案精准度。
|
|
|
+
|
|
|
+**架构:** 后端新增 Entity/Mapper/Service/Controller(`/api/health-status` get+save,统一 POST),`DatabaseInitializer` 加迁移 229 建表并同步 `schema.sql`;修改 `AIChatController.sendMessage` 解析前端传来的 `healthStatus`/`dietPrefs` JSON 注入 inputs。前端新增独立档案页 `health-status-form.vue`,修改 `health-plan-summary.vue`(读取+弹窗+传参)与 `api.js`(新增 API + aiSendMessage 透传)。饮食偏好复用现有 `/api/diet/preferences/current-member`,不新增端点。
|
|
|
+
|
|
|
+**技术栈:** Java 8 + Spring Boot 2.7.18 + MyBatis-Plus + MySQL 8(JSON 列);uni-app Vue 2 小程序(Options API)。
|
|
|
+
|
|
|
+**规格:** `docs/superpowers/specs/2026-08-13-health-status-survey-design.md`
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 文件结构
|
|
|
+
|
|
|
+**后端(cfc-backend/src/main/java/com/etotem/cfc/):**
|
|
|
+- 创建 `entity/HealthStatus.java` — 实体,`@TableName("health_status")`
|
|
|
+- 创建 `mapper/HealthStatusMapper.java` — BaseMapper
|
|
|
+- 创建 `service/HealthStatusService.java` — 接口(getByUserId/save)
|
|
|
+- 创建 `service/impl/HealthStatusServiceImpl.java` — upsert 实现
|
|
|
+- 创建 `controller/HealthStatusController.java` — `/api/health-status` get/save
|
|
|
+- 修改 `config/DatabaseInitializer.java` — 迁移 229 建表
|
|
|
+- 修改 `controller/ai/AIChatController.java` — 注入 health_status/diet_preferences 到 inputs
|
|
|
+
|
|
|
+**资源:**
|
|
|
+- 修改 `cfc-backend/src/main/resources/schema.sql` — 追加 `health_status` 建表语句
|
|
|
+
|
|
|
+**前端(cfc-frontend/):**
|
|
|
+- 创建 `pages/health/health-status-form.vue` — 独立档案页
|
|
|
+- 修改 `pages/health/health-plan-summary.vue` — 读取/弹窗/传参
|
|
|
+- 修改 `pages/health-main/index.vue` — 加「健康档案」入口
|
|
|
+- 修改 `pages.json` — 注册 health-status-form
|
|
|
+- 修改 `utils/api.js` — 新增 getHealthStatus/saveHealthStatus + aiSendMessage 透传
|
|
|
+
|
|
|
+**文档:**
|
|
|
+- 创建 `docs/superpowers/plans/2026-08-13-health-status-survey.md`(本文件)
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 任务 1:数据库迁移 + schema.sql
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`(文件末尾 `runMigrations()` 内,`// 迁移228` 之后)
|
|
|
+- 修改:`cfc-backend/src/main/resources/schema.sql`(文件末尾追加)
|
|
|
+
|
|
|
+- [ ] **步骤 1:在 DatabaseInitializer 末尾添加迁移 229**
|
|
|
+
|
|
|
+在 `// 迁移228: file_record 添加 file_type 和 description 列` 之后、`runMigrations()` 的闭合 `}` 之前添加:
|
|
|
+
|
|
|
+```java
|
|
|
+// 迁移229: 创建 health_status 表(当前状态调研-健康现状档案,SS-2026-08-13)
|
|
|
+try {
|
|
|
+ jdbcTemplate.execute(
|
|
|
+ "CREATE TABLE IF NOT EXISTS health_status (" +
|
|
|
+ "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
|
|
|
+ "user_id BIGINT NOT NULL COMMENT '所属账号(一个账号一份)', " +
|
|
|
+ "height_cm DECIMAL(5,1) DEFAULT NULL COMMENT '身高(cm)', " +
|
|
|
+ "weight_kg DECIMAL(5,1) DEFAULT NULL COMMENT '体重(kg)', " +
|
|
|
+ "blood_pressure VARCHAR(20) DEFAULT NULL COMMENT '血压,如 120/80', " +
|
|
|
+ "blood_glucose DECIMAL(4,1) DEFAULT NULL COMMENT '空腹血糖(mmol/L)', " +
|
|
|
+ "blood_lipids JSON DEFAULT NULL COMMENT '血脂 [{name,value,unit,note}]', " +
|
|
|
+ "disease_history JSON DEFAULT NULL COMMENT '疾病史 [{name,note}]', " +
|
|
|
+ "medications JSON DEFAULT NULL COMMENT '在服药物/治疗 [{name,note}]', " +
|
|
|
+ "notes VARCHAR(500) DEFAULT NULL COMMENT '其他补充说明', " +
|
|
|
+ "filled_at DATETIME DEFAULT NULL COMMENT '首次填写时间', " +
|
|
|
+ "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', " +
|
|
|
+ "UNIQUE KEY uk_user (user_id)" +
|
|
|
+ ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='当前状态调研-健康现状档案'"
|
|
|
+ );
|
|
|
+ log.info("已创建health_status表");
|
|
|
+} catch (Exception e) {
|
|
|
+ log.warn("创建health_status表失败: " + e.getMessage());
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:同步 schema.sql**
|
|
|
+
|
|
|
+在 `cfc-backend/src/main/resources/schema.sql` 末尾追加:
|
|
|
+
|
|
|
+```sql
|
|
|
+-- 当前状态调研-健康现状档案(迁移229)
|
|
|
+CREATE TABLE IF NOT EXISTS health_status (
|
|
|
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
|
+ user_id BIGINT NOT NULL COMMENT '所属账号(一个账号一份)',
|
|
|
+ height_cm DECIMAL(5,1) DEFAULT NULL COMMENT '身高(cm)',
|
|
|
+ weight_kg DECIMAL(5,1) DEFAULT NULL COMMENT '体重(kg)',
|
|
|
+ blood_pressure VARCHAR(20) DEFAULT NULL COMMENT '血压,如 120/80',
|
|
|
+ blood_glucose DECIMAL(4,1) DEFAULT NULL COMMENT '空腹血糖(mmol/L)',
|
|
|
+ blood_lipids JSON DEFAULT NULL COMMENT '血脂 [{name,value,unit,note}]',
|
|
|
+ disease_history JSON DEFAULT NULL COMMENT '疾病史 [{name,note}]',
|
|
|
+ medications JSON DEFAULT NULL COMMENT '在服药物/治疗 [{name,note}]',
|
|
|
+ notes VARCHAR(500) DEFAULT NULL COMMENT '其他补充说明',
|
|
|
+ filled_at DATETIME DEFAULT NULL COMMENT '首次填写时间',
|
|
|
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
|
|
+ UNIQUE KEY uk_user (user_id)
|
|
|
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='当前状态调研-健康现状档案';
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 3:验证编译**
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn clean compile`
|
|
|
+预期:BUILD SUCCESS(迁移是幂等 try-catch,无需运行时验证)
|
|
|
+
|
|
|
+- [ ] **步骤 4:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java cfc-backend/src/main/resources/schema.sql
|
|
|
+git commit -m "feat(db): health_status 表迁移229 + schema 同步"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 任务 2:Entity + Mapper
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/entity/HealthStatus.java`
|
|
|
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthStatusMapper.java`
|
|
|
+
|
|
|
+- [ ] **步骤 1:创建 HealthStatus 实体**
|
|
|
+
|
|
|
+```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.io.Serializable;
|
|
|
+import java.math.BigDecimal;
|
|
|
+import java.util.Date;
|
|
|
+
|
|
|
+@Data
|
|
|
+@TableName("health_status")
|
|
|
+public class HealthStatus implements Serializable {
|
|
|
+
|
|
|
+ @TableId(type = IdType.AUTO)
|
|
|
+ private Long id;
|
|
|
+
|
|
|
+ /** 所属账号(一个账号一份) */
|
|
|
+ private Long userId;
|
|
|
+
|
|
|
+ /** 身高(cm) */
|
|
|
+ private BigDecimal heightCm;
|
|
|
+
|
|
|
+ /** 体重(kg) */
|
|
|
+ private BigDecimal weightKg;
|
|
|
+
|
|
|
+ /** 血压,如 120/80 */
|
|
|
+ private String bloodPressure;
|
|
|
+
|
|
|
+ /** 空腹血糖(mmol/L) */
|
|
|
+ private BigDecimal bloodGlucose;
|
|
|
+
|
|
|
+ /** 血脂 JSON: [{name,value,unit,note}] */
|
|
|
+ private String bloodLipids;
|
|
|
+
|
|
|
+ /** 疾病史 JSON: [{name,note}] */
|
|
|
+ private String diseaseHistory;
|
|
|
+
|
|
|
+ /** 在服药物/治疗 JSON: [{name,note}] */
|
|
|
+ private String medications;
|
|
|
+
|
|
|
+ /** 其他补充说明 */
|
|
|
+ private String notes;
|
|
|
+
|
|
|
+ /** 首次填写时间 */
|
|
|
+ private Date filledAt;
|
|
|
+
|
|
|
+ /** 更新时间 */
|
|
|
+ private Date updatedAt;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:创建 Mapper**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.mapper;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
|
|
+import com.etotem.cfc.entity.HealthStatus;
|
|
|
+import org.apache.ibatis.annotations.Mapper;
|
|
|
+
|
|
|
+@Mapper
|
|
|
+public interface HealthStatusMapper extends BaseMapper<HealthStatus> {
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 3:验证编译**
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn clean compile`
|
|
|
+预期:BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **步骤 4:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/HealthStatus.java cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthStatusMapper.java
|
|
|
+git commit -m "feat(health-status): 新增 HealthStatus 实体与 Mapper"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 任务 3:Service 接口 + 实现(upsert)
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/service/HealthStatusService.java`
|
|
|
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthStatusServiceImpl.java`
|
|
|
+
|
|
|
+- [ ] **步骤 1:创建 Service 接口**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.etotem.cfc.entity.HealthStatus;
|
|
|
+
|
|
|
+public interface HealthStatusService {
|
|
|
+
|
|
|
+ /** 查询指定账号的健康现状,未填写返回 null */
|
|
|
+ HealthStatus getByUserId(Long userId);
|
|
|
+
|
|
|
+ /** 保存/更新(upsert:有则更新,无则插入;filledAt 仅首次赋值) */
|
|
|
+ HealthStatus save(Long userId, HealthStatus status);
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:创建实现类**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.service.impl;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.etotem.cfc.entity.HealthStatus;
|
|
|
+import com.etotem.cfc.mapper.HealthStatusMapper;
|
|
|
+import com.etotem.cfc.service.HealthStatusService;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.util.Date;
|
|
|
+
|
|
|
+@Service
|
|
|
+public class HealthStatusServiceImpl implements HealthStatusService {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private HealthStatusMapper healthStatusMapper;
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public HealthStatus getByUserId(Long userId) {
|
|
|
+ if (userId == null) return null;
|
|
|
+ return healthStatusMapper.selectOne(
|
|
|
+ new LambdaQueryWrapper<HealthStatus>()
|
|
|
+ .eq(HealthStatus::getUserId, userId));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public HealthStatus save(Long userId, HealthStatus status) {
|
|
|
+ if (userId == null) throw new IllegalArgumentException("userId不能为空");
|
|
|
+ if (status == null) throw new IllegalArgumentException("健康现状不能为空");
|
|
|
+
|
|
|
+ HealthStatus existing = getByUserId(userId);
|
|
|
+ status.setUserId(userId);
|
|
|
+ if (existing == null) {
|
|
|
+ status.setId(null);
|
|
|
+ status.setFilledAt(new Date());
|
|
|
+ status.setUpdatedAt(new Date());
|
|
|
+ healthStatusMapper.insert(status);
|
|
|
+ } else {
|
|
|
+ status.setId(existing.getId());
|
|
|
+ status.setFilledAt(existing.getFilledAt());
|
|
|
+ status.setUpdatedAt(new Date());
|
|
|
+ healthStatusMapper.updateById(status);
|
|
|
+ }
|
|
|
+ return getByUserId(userId);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 3:验证编译**
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn clean compile`
|
|
|
+预期:BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **步骤 4:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/HealthStatusService.java cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthStatusServiceImpl.java
|
|
|
+git commit -m "feat(health-status): HealthStatusService upsert 实现"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 任务 4:HealthStatusController
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/controller/HealthStatusController.java`
|
|
|
+
|
|
|
+> 检查路由冲突:现有 Controller 无 `/api/health-status` 前缀(可用 `grep -rn 'health-status' cfc-backend/src/main/java/com/etotem/cfc/controller/` 确认)。类名 `HealthStatusController` 无 Bean 冲突(全库唯一)。
|
|
|
+
|
|
|
+- [ ] **步骤 1:创建 Controller**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.controller;
|
|
|
+
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
+import com.etotem.cfc.entity.HealthStatus;
|
|
|
+import com.etotem.cfc.service.HealthStatusService;
|
|
|
+import io.swagger.v3.oas.annotations.Operation;
|
|
|
+import io.swagger.v3.oas.annotations.tags.Tag;
|
|
|
+import org.springframework.web.bind.annotation.PostMapping;
|
|
|
+import org.springframework.web.bind.annotation.RequestAttribute;
|
|
|
+import org.springframework.web.bind.annotation.RequestBody;
|
|
|
+import org.springframework.web.bind.annotation.RequestMapping;
|
|
|
+import org.springframework.web.bind.annotation.RestController;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.math.BigDecimal;
|
|
|
+
|
|
|
+@Tag(name = "健康现状档案", description = "当前状态调研-健康现状档案接口")
|
|
|
+@RestController
|
|
|
+@RequestMapping("/api/health-status")
|
|
|
+public class HealthStatusController {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private HealthStatusService healthStatusService;
|
|
|
+
|
|
|
+ @Operation(summary = "查询当前账号健康现状(未填写返回 null)")
|
|
|
+ @PostMapping("/get")
|
|
|
+ public Result<HealthStatus> get(@RequestAttribute("userId") Long userId) {
|
|
|
+ return Result.success(healthStatusService.getByUserId(userId));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Operation(summary = "保存/更新当前账号健康现状(upsert)")
|
|
|
+ @PostMapping("/save")
|
|
|
+ public Result<HealthStatus> save(
|
|
|
+ @RequestAttribute("userId") Long userId,
|
|
|
+ @RequestBody HealthStatus status) {
|
|
|
+ if (status == null || isEmpty(status)) {
|
|
|
+ return Result.error("请至少填写一项");
|
|
|
+ }
|
|
|
+ String validateMsg = validate(status);
|
|
|
+ if (validateMsg != null) {
|
|
|
+ return Result.error(validateMsg);
|
|
|
+ }
|
|
|
+ return Result.success(healthStatusService.save(userId, status));
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 至少填写一项才算有效 */
|
|
|
+ private boolean isEmpty(HealthStatus s) {
|
|
|
+ return s.getHeightCm() == null && s.getWeightKg() == null
|
|
|
+ && s.getBloodPressure() == null && s.getBloodGlucose() == null
|
|
|
+ && s.getBloodLipids() == null && s.getDiseaseHistory() == null
|
|
|
+ && s.getMedications() == null && s.getNotes() == null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 数值校验,返回错误信息或 null */
|
|
|
+ private String validate(HealthStatus s) {
|
|
|
+ if (s.getHeightCm() != null && (s.getHeightCm().compareTo(BigDecimal.valueOf(30)) < 0
|
|
|
+ || s.getHeightCm().compareTo(BigDecimal.valueOf(250)) > 0)) {
|
|
|
+ return "身高需在 30-250 cm 之间";
|
|
|
+ }
|
|
|
+ if (s.getWeightKg() != null && (s.getWeightKg().compareTo(BigDecimal.valueOf(3)) < 0
|
|
|
+ || s.getWeightKg().compareTo(BigDecimal.valueOf(300)) > 0)) {
|
|
|
+ return "体重需在 3-300 kg 之间";
|
|
|
+ }
|
|
|
+ if (s.getBloodPressure() != null && !s.getBloodPressure().matches("\\d{2,3}/\\d{2,3}")) {
|
|
|
+ return "血压格式应为 120/80";
|
|
|
+ }
|
|
|
+ if (s.getBloodGlucose() != null && (s.getBloodGlucose().compareTo(BigDecimal.ONE) < 0
|
|
|
+ || s.getBloodGlucose().compareTo(BigDecimal.valueOf(30)) > 0)) {
|
|
|
+ return "血糖需在 1-30 mmol/L 之间";
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:检查路由冲突**
|
|
|
+
|
|
|
+运行:`grep -rn '@Mapping' cfc-backend/src/main/java/com/etotem/cfc/controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u | grep health-status`
|
|
|
+预期:仅输出 `/api/health-status/get` 与 `/api/health-status/save`
|
|
|
+
|
|
|
+- [ ] **步骤 3:验证编译**
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn clean compile`
|
|
|
+预期:BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **步骤 4:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/HealthStatusController.java
|
|
|
+git commit -m "feat(health-status): HealthStatusController get/save 接口"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 任务 5:AIChatController 注入健康现状到 Dify inputs
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java`
|
|
|
+
|
|
|
+> 当前 `sendMessage` 已用 `@RequestBody Map<String, String> params` 解析 `query/conversationId/reportId/surveyId`,在 `inputs = familyContextService.buildContext(...)` 之后、`aiService.enrichInputsWithMemory(...)` 之前插入解析逻辑。`ObjectMapper` 用新建实例(与 `AIService` 中 `private final ObjectMapper objectMapper = new ObjectMapper();` 同模式)。
|
|
|
+
|
|
|
+- [ ] **步骤 1:添加 import 与 ObjectMapper 字段**
|
|
|
+
|
|
|
+在类头部现有 import 之后添加:
|
|
|
+
|
|
|
+```java
|
|
|
+import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
+```
|
|
|
+
|
|
|
+在类内字段区(`private TaskParseService taskParseService;` 之后)添加:
|
|
|
+
|
|
|
+```java
|
|
|
+private final ObjectMapper objectMapper = new ObjectMapper();
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:在 sendMessage 中解析并注入**
|
|
|
+
|
|
|
+在 `Map<String, Object> inputs;` 与 `if (reportId != null && surveyId != null) {...}` 组装块**之后**、`// 注入mascot信息到Dify inputs` 之前,插入:
|
|
|
+
|
|
|
+```java
|
|
|
+// 注入当前状态调研数据(健康现状 + 饮食偏好)
|
|
|
+String healthStatusStr = params.get("healthStatus");
|
|
|
+String dietPrefsStr = params.get("dietPrefs");
|
|
|
+if (healthStatusStr != null && !healthStatusStr.trim().isEmpty()) {
|
|
|
+ try {
|
|
|
+ inputs.put("health_status", objectMapper.readValue(healthStatusStr, Map.class));
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("healthStatus解析失败: {}", e.getMessage());
|
|
|
+ }
|
|
|
+}
|
|
|
+if (dietPrefsStr != null && !dietPrefsStr.trim().isEmpty()) {
|
|
|
+ try {
|
|
|
+ inputs.put("diet_preferences", objectMapper.readValue(dietPrefsStr, Map.class));
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("dietPrefs解析失败: {}", e.getMessage());
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 3:验证编译**
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn clean compile`
|
|
|
+预期:BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **步骤 4:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java
|
|
|
+git commit -m "feat(ai): AIChatController 注入 health_status/diet_preferences 到 Dify inputs"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 任务 6:前端 api.js
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`cfc-frontend/utils/api.js`
|
|
|
+
|
|
|
+- [ ] **步骤 1:新增健康现状 API**
|
|
|
+
|
|
|
+在 `getDietPreferences`(约 2614 行)附近添加:
|
|
|
+
|
|
|
+```js
|
|
|
+export const getHealthStatus = () => request('/api/health-status/get', 'POST', {})
|
|
|
+export const saveHealthStatus = (data) => request('/api/health-status/save', 'POST', data)
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:修改 aiSendMessage 透传 healthStatus/dietPrefs**
|
|
|
+
|
|
|
+当前代码(约 1317-1345 行):
|
|
|
+
|
|
|
+```js
|
|
|
+export const aiSendMessage = (data) => {
|
|
|
+ var query = data && data.query ? data.query : ''
|
|
|
+ var conversationId = data && data.conversationId ? data.conversationId : ''
|
|
|
+ var userId = uni.getStorageSync('userId') || ''
|
|
|
+ var langgraphBody = {
|
|
|
+ query: query,
|
|
|
+ user: userId,
|
|
|
+ user_id: userId,
|
|
|
+ conversation_id: conversationId,
|
|
|
+ response_mode: 'blocking',
|
|
|
+ bot_name: 'AI管家'
|
|
|
+ }
|
|
|
+ if (data && data.reportId) {
|
|
|
+ langgraphBody.inputs = { report_id: data.reportId }
|
|
|
+ }
|
|
|
+ return langgraphRequest({
|
|
|
+ langgraphPath: '/api/v1/chat/completion',
|
|
|
+ langgraphBody: langgraphBody,
|
|
|
+ difyFallbackPath: '/api/ai/chat/send',
|
|
|
+ difyFallbackData: data,
|
|
|
+ transform: ...
|
|
|
+ })
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+将 `if (data && data.reportId) { langgraphBody.inputs = { report_id: data.reportId } }` 替换为:
|
|
|
+
|
|
|
+```js
|
|
|
+ var inputs = {}
|
|
|
+ if (data && data.reportId) {
|
|
|
+ inputs.report_id = data.reportId
|
|
|
+ }
|
|
|
+ if (data && data.healthStatus) {
|
|
|
+ inputs.health_status = data.healthStatus
|
|
|
+ }
|
|
|
+ if (data && data.dietPrefs) {
|
|
|
+ inputs.diet_preferences = data.dietPrefs
|
|
|
+ }
|
|
|
+ if (Object.keys(inputs).length > 0) {
|
|
|
+ langgraphBody.inputs = inputs
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+> `difyFallbackData: data` 已透传整个 data 对象,`healthStatus`/`dietPrefs` 会随 POST body 到达 `/api/ai/chat/send`。**注意:** 前端在 Task 8 中组装 `sendData` 时必须用 `JSON.stringify()` 把对象转成 JSON 字符串(后端 `Map<String,String>` 接收,再 `readValue` 解析回对象),否则 Jackson 无法把 JSON 对象反序列化进 String 字段会报错。
|
|
|
+
|
|
|
+- [ ] **步骤 3:语法校验**
|
|
|
+
|
|
|
+运行:`cd cfc-frontend && node --check utils/api.js`
|
|
|
+预期:无输出(exit code 0)。若 node 版本对 ESM/`import` 报错(`--check` 在 CommonJS 下会报 `Cannot use import statement`),则用 `npx babel-node` 或跳过该文件级校验,改为人工核对修改段括号/逗号闭合。
|
|
|
+
|
|
|
+- [ ] **步骤 4:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-frontend/utils/api.js
|
|
|
+git commit -m "feat(frontend): api.js 新增 health-status API 并透传 healthStatus/dietPrefs"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 任务 7:独立档案页 health-status-form.vue + 注册 + 入口
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 创建:`cfc-frontend/pages/health/health-status-form.vue`
|
|
|
+- 修改:`cfc-frontend/pages.json`(health 分包,`health-plan-summary` 之后注册)
|
|
|
+- 修改:`cfc-frontend/pages/health-main/index.vue`(「智能健康方案」feat-card 旁加「健康档案」card)
|
|
|
+
|
|
|
+- [ ] **步骤 1:创建档案页**
|
|
|
+
|
|
|
+> 遵循小程序限制:禁止可选链 `?.`(用 `&&`)、禁止 `:key` 表达式(用 `:key="idx"` 索引或方法)、禁止 `new Date(string)`。Vue 2 Options API。
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <view class="hs-page">
|
|
|
+ <view class="nav-bar">
|
|
|
+ <text class="nav-back" @click="goBack">\ue601 返回</text>
|
|
|
+ <text class="nav-title">健康档案</text>
|
|
|
+ <view class="nav-placeholder"></view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="hs-tip">
|
|
|
+ <text class="hs-tip-text">填写当前健康现状,生成方案时 AI 将参考这些信息,方案更精准。可不填,但未填时方案可能不够准确。</text>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="form-section">
|
|
|
+ <view class="form-title">基础体征</view>
|
|
|
+ <view class="form-row">
|
|
|
+ <text class="form-label">身高 (cm)</text>
|
|
|
+ <input class="form-input" type="digit" v-model="form.heightCm" placeholder="如 170" />
|
|
|
+ </view>
|
|
|
+ <view class="form-row">
|
|
|
+ <text class="form-label">体重 (kg)</text>
|
|
|
+ <input class="form-input" type="digit" v-model="form.weightKg" placeholder="如 60" />
|
|
|
+ </view>
|
|
|
+ <view class="form-row">
|
|
|
+ <text class="form-label">血压</text>
|
|
|
+ <input class="form-input" type="text" v-model="form.bloodPressure" placeholder="如 120/80" />
|
|
|
+ </view>
|
|
|
+ <view class="form-row">
|
|
|
+ <text class="form-label">空腹血糖 (mmol/L)</text>
|
|
|
+ <input class="form-input" type="digit" v-model="form.bloodGlucose" placeholder="如 5.6" />
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="form-section">
|
|
|
+ <view class="form-title">血脂</view>
|
|
|
+ <view v-for="(item, idx) in lipidRows" :key="idx" class="lipid-row">
|
|
|
+ <input class="lipid-name" type="text" v-model="item.name" placeholder="指标名" />
|
|
|
+ <input class="lipid-value" type="digit" v-model="item.value" placeholder="数值" />
|
|
|
+ <input class="lipid-unit" type="text" v-model="item.unit" placeholder="单位" />
|
|
|
+ <text class="lipid-del" @click="removeLipid(idx)">删除</text>
|
|
|
+ </view>
|
|
|
+ <text class="add-link" @click="addLipid">+ 添加血脂指标</text>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="form-section">
|
|
|
+ <view class="form-title">疾病史</view>
|
|
|
+ <view class="tag-wrap">
|
|
|
+ <view
|
|
|
+ v-for="(tag, idx) in diseaseTags"
|
|
|
+ :key="idx"
|
|
|
+ class="tag-item"
|
|
|
+ :class="{ 'tag-active': form.diseaseNames.indexOf(tag) >= 0 }"
|
|
|
+ @click="toggleDisease(tag)"
|
|
|
+ >
|
|
|
+ <text>{{ tag }}</text>
|
|
|
+ </view>
|
|
|
+ <view v-for="(custom, idx) in customDiseases" :key="'c' + idx" class="tag-item tag-active">
|
|
|
+ <text>{{ custom }}</text>
|
|
|
+ <text class="tag-del" @click="removeCustomDisease(idx)">×</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ <view class="custom-row">
|
|
|
+ <input class="custom-input" type="text" v-model="diseaseInput" placeholder="输入其他疾病" />
|
|
|
+ <text class="add-link" @click="addCustomDisease">添加</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="form-section">
|
|
|
+ <view class="form-title">在服药物/治疗</view>
|
|
|
+ <view v-for="(med, idx) in medRows" :key="idx" class="med-row">
|
|
|
+ <input class="med-name" type="text" v-model="med.name" placeholder="药物/治疗名称" />
|
|
|
+ <input class="med-note" type="text" v-model="med.note" placeholder="备注(可选)" />
|
|
|
+ <text class="lipid-del" @click="removeMed(idx)">删除</text>
|
|
|
+ </view>
|
|
|
+ <text class="add-link" @click="addMed">+ 添加药物/治疗</text>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="form-section">
|
|
|
+ <view class="form-title">其他补充</view>
|
|
|
+ <textarea class="notes-area" v-model="form.notes" placeholder="其他想补充的健康信息(选填)" :maxlength="500" />
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view class="save-bar">
|
|
|
+ <button class="save-btn" :disabled="saving" @click="save">{{ saving ? '保存中...' : '保存' }}</button>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import { getHealthStatus, saveHealthStatus } from '../../utils/api'
|
|
|
+
|
|
|
+var DISEASE_PRESETS = ['高血压', '糖尿病', '高血脂', '过敏性鼻炎', '哮喘', '贫血', '胃炎', '甲状腺疾病', '心脏疾病', '肾脏疾病']
|
|
|
+
|
|
|
+export default {
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ form: {
|
|
|
+ heightCm: '',
|
|
|
+ weightKg: '',
|
|
|
+ bloodPressure: '',
|
|
|
+ bloodGlucose: '',
|
|
|
+ bloodLipids: '',
|
|
|
+ diseaseHistory: '',
|
|
|
+ medications: '',
|
|
|
+ notes: ''
|
|
|
+ },
|
|
|
+ lipidRows: [],
|
|
|
+ diseaseTags: DISEASE_PRESETS.slice(),
|
|
|
+ diseaseNames: [],
|
|
|
+ customDiseases: [],
|
|
|
+ diseaseInput: '',
|
|
|
+ medRows: [],
|
|
|
+ saving: false
|
|
|
+ }
|
|
|
+ },
|
|
|
+ onLoad: function() {
|
|
|
+ this.load()
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ load: function() {
|
|
|
+ var self = this
|
|
|
+ getHealthStatus().then(function(res) {
|
|
|
+ if (res && res.code === 200 && res.data) {
|
|
|
+ var d = res.data
|
|
|
+ self.form.heightCm = d.heightCm || ''
|
|
|
+ self.form.weightKg = d.weightKg || ''
|
|
|
+ self.form.bloodPressure = d.bloodPressure || ''
|
|
|
+ self.form.bloodGlucose = d.bloodGlucose || ''
|
|
|
+ self.form.notes = d.notes || ''
|
|
|
+ self.lipidRows = self.parseLipids(d.bloodLipids)
|
|
|
+ self.loadDiseases(d.diseaseHistory)
|
|
|
+ self.medRows = self.parseMeds(d.medications)
|
|
|
+ }
|
|
|
+ }).catch(function() {})
|
|
|
+ },
|
|
|
+ parseLipids: function(json) {
|
|
|
+ var list = []
|
|
|
+ try {
|
|
|
+ var arr = JSON.parse(json || '[]')
|
|
|
+ if (arr instanceof Array) {
|
|
|
+ arr.forEach(function(item) {
|
|
|
+ list.push({ name: item.name || '', value: item.value || '', unit: item.unit || '', note: item.note || '' })
|
|
|
+ })
|
|
|
+ }
|
|
|
+ } catch (e) {}
|
|
|
+ if (list.length === 0) list.push({ name: '', value: '', unit: '', note: '' })
|
|
|
+ return list
|
|
|
+ },
|
|
|
+ loadDiseases: function(json) {
|
|
|
+ var self = this
|
|
|
+ try {
|
|
|
+ var arr = JSON.parse(json || '[]')
|
|
|
+ if (arr instanceof Array) {
|
|
|
+ arr.forEach(function(item) {
|
|
|
+ if (item.name && self.diseaseNames.indexOf(item.name) < 0) {
|
|
|
+ self.diseaseNames.push(item.name)
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+ } catch (e) {}
|
|
|
+ },
|
|
|
+ parseMeds: function(json) {
|
|
|
+ var list = []
|
|
|
+ try {
|
|
|
+ var arr = JSON.parse(json || '[]')
|
|
|
+ if (arr instanceof Array) {
|
|
|
+ arr.forEach(function(item) {
|
|
|
+ list.push({ name: item.name || '', note: item.note || '' })
|
|
|
+ })
|
|
|
+ }
|
|
|
+ } catch (e) {}
|
|
|
+ if (list.length === 0) list.push({ name: '', note: '' })
|
|
|
+ return list
|
|
|
+ },
|
|
|
+ addLipid: function() {
|
|
|
+ this.lipidRows.push({ name: '', value: '', unit: '', note: '' })
|
|
|
+ },
|
|
|
+ removeLipid: function(idx) {
|
|
|
+ if (this.lipidRows.length > 1) this.lipidRows.splice(idx, 1)
|
|
|
+ },
|
|
|
+ toggleDisease: function(tag) {
|
|
|
+ var idx = this.diseaseNames.indexOf(tag)
|
|
|
+ if (idx >= 0) this.diseaseNames.splice(idx, 1)
|
|
|
+ else this.diseaseNames.push(tag)
|
|
|
+ },
|
|
|
+ addCustomDisease: function() {
|
|
|
+ var name = (this.diseaseInput || '').trim()
|
|
|
+ if (!name) return
|
|
|
+ if (this.diseaseNames.indexOf(name) < 0) {
|
|
|
+ this.diseaseNames.push(name)
|
|
|
+ this.customDiseases.push(name)
|
|
|
+ }
|
|
|
+ this.diseaseInput = ''
|
|
|
+ },
|
|
|
+ removeCustomDisease: function(idx) {
|
|
|
+ var name = this.customDiseases[idx]
|
|
|
+ this.customDiseases.splice(idx, 1)
|
|
|
+ var i = this.diseaseNames.indexOf(name)
|
|
|
+ if (i >= 0) this.diseaseNames.splice(i, 1)
|
|
|
+ },
|
|
|
+ addMed: function() {
|
|
|
+ this.medRows.push({ name: '', note: '' })
|
|
|
+ },
|
|
|
+ removeMed: function(idx) {
|
|
|
+ if (this.medRows.length > 1) this.medRows.splice(idx, 1)
|
|
|
+ },
|
|
|
+ buildPayload: function() {
|
|
|
+ var lipids = []
|
|
|
+ this.lipidRows.forEach(function(item) {
|
|
|
+ if (item.name && item.value) {
|
|
|
+ lipids.push({ name: item.name, value: parseFloat(item.value), unit: item.unit || '', note: item.note || '' })
|
|
|
+ }
|
|
|
+ })
|
|
|
+ var diseases = []
|
|
|
+ this.diseaseNames.forEach(function(name) {
|
|
|
+ diseases.push({ name: name, note: '' })
|
|
|
+ })
|
|
|
+ var meds = []
|
|
|
+ this.medRows.forEach(function(item) {
|
|
|
+ if (item.name) {
|
|
|
+ meds.push({ name: item.name, note: item.note || '' })
|
|
|
+ }
|
|
|
+ })
|
|
|
+ var payload = {}
|
|
|
+ if (this.form.heightCm) payload.heightCm = Number(this.form.heightCm)
|
|
|
+ if (this.form.weightKg) payload.weightKg = Number(this.form.weightKg)
|
|
|
+ if (this.form.bloodPressure) payload.bloodPressure = this.form.bloodPressure
|
|
|
+ if (this.form.bloodGlucose) payload.bloodGlucose = Number(this.form.bloodGlucose)
|
|
|
+ if (lipids.length > 0) payload.bloodLipids = JSON.stringify(lipids)
|
|
|
+ if (diseases.length > 0) payload.diseaseHistory = JSON.stringify(diseases)
|
|
|
+ if (meds.length > 0) payload.medications = JSON.stringify(meds)
|
|
|
+ if (this.form.notes) payload.notes = this.form.notes
|
|
|
+ return payload
|
|
|
+ },
|
|
|
+ save: function() {
|
|
|
+ var self = this
|
|
|
+ var payload = this.buildPayload()
|
|
|
+ if (Object.keys(payload).length === 0) {
|
|
|
+ uni.showToast({ title: '请至少填写一项', icon: 'none' })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (this.form.bloodPressure && !/^\d{2,3}\/\d{2,3}$/.test(this.form.bloodPressure)) {
|
|
|
+ uni.showToast({ title: '血压格式应为 120/80', icon: 'none' })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ self.saving = true
|
|
|
+ saveHealthStatus(payload).then(function(res) {
|
|
|
+ self.saving = false
|
|
|
+ if (res && res.code === 200) {
|
|
|
+ uni.showToast({ title: '已保存', icon: 'success' })
|
|
|
+ setTimeout(function() { uni.navigateBack() }, 800)
|
|
|
+ } else {
|
|
|
+ uni.showToast({ title: (res && res.message) || '保存失败', icon: 'none' })
|
|
|
+ }
|
|
|
+ }).catch(function() {
|
|
|
+ self.saving = false
|
|
|
+ uni.showToast({ title: '保存失败,请检查网络', icon: 'none' })
|
|
|
+ })
|
|
|
+ },
|
|
|
+ goBack: function() {
|
|
|
+ uni.navigateBack()
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.hs-page { min-height: 100vh; background: #F5F7FA; padding-bottom: 140rpx; }
|
|
|
+.nav-bar { display: flex; align-items: center; justify-content: space-between; height: 88rpx; padding: 0 30rpx; background: #fff; border-bottom: 1rpx solid #eee; position: sticky; top: 0; z-index: 10; }
|
|
|
+.nav-back { font-size: 32rpx; color: #F97316; }
|
|
|
+.nav-title { font-size: 34rpx; font-weight: bold; color: #333; }
|
|
|
+.nav-placeholder { width: 80rpx; }
|
|
|
+.hs-tip { margin: 24rpx 30rpx; padding: 20rpx 24rpx; background: #FFF7E6; border-radius: 12rpx; }
|
|
|
+.hs-tip-text { font-size: 26rpx; color: #8A6D3B; line-height: 1.6; }
|
|
|
+.form-section { margin: 0 30rpx 24rpx; background: #fff; border-radius: 16rpx; padding: 24rpx; }
|
|
|
+.form-title { font-size: 30rpx; font-weight: bold; color: #333; margin-bottom: 20rpx; }
|
|
|
+.form-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20rpx; }
|
|
|
+.form-label { font-size: 28rpx; color: #555; }
|
|
|
+.form-input { flex: 1; margin-left: 30rpx; height: 68rpx; background: #F5F7FA; border-radius: 10rpx; padding: 0 20rpx; font-size: 28rpx; text-align: right; }
|
|
|
+.lipid-row { display: flex; align-items: center; margin-bottom: 16rpx; }
|
|
|
+.lipid-name { width: 30%; height: 64rpx; background: #F5F7FA; border-radius: 10rpx; padding: 0 16rpx; font-size: 26rpx; margin-right: 12rpx; }
|
|
|
+.lipid-value { width: 25%; height: 64rpx; background: #F5F7FA; border-radius: 10rpx; padding: 0 16rpx; font-size: 26rpx; margin-right: 12rpx; }
|
|
|
+.lipid-unit { width: 22%; height: 64rpx; background: #F5F7FA; border-radius: 10rpx; padding: 0 16rpx; font-size: 26rpx; margin-right: 12rpx; }
|
|
|
+.lipid-del { font-size: 26rpx; color: #E64340; }
|
|
|
+.tag-wrap { display: flex; flex-wrap: wrap; }
|
|
|
+.tag-item { padding: 12rpx 24rpx; background: #F5F7FA; border-radius: 30rpx; font-size: 26rpx; color: #666; margin: 0 16rpx 16rpx 0; }
|
|
|
+.tag-active { background: #F97316; color: #fff; }
|
|
|
+.tag-del { margin-left: 8rpx; font-size: 24rpx; }
|
|
|
+.custom-row { display: flex; align-items: center; margin-top: 8rpx; }
|
|
|
+.custom-input { flex: 1; height: 64rpx; background: #F5F7FA; border-radius: 10rpx; padding: 0 20rpx; font-size: 26rpx; margin-right: 20rpx; }
|
|
|
+.add-link { font-size: 28rpx; color: #F97316; }
|
|
|
+.med-row { display: flex; align-items: center; margin-bottom: 16rpx; }
|
|
|
+.med-name { flex: 1; height: 64rpx; background: #F5F7FA; border-radius: 10rpx; padding: 0 16rpx; font-size: 26rpx; margin-right: 12rpx; }
|
|
|
+.med-note { flex: 1; height: 64rpx; background: #F5F7FA; border-radius: 10rpx; padding: 0 16rpx; font-size: 26rpx; margin-right: 12rpx; }
|
|
|
+.notes-area { width: 100%; height: 160rpx; background: #F5F7FA; border-radius: 10rpx; padding: 16rpx 20rpx; font-size: 26rpx; box-sizing: border-box; }
|
|
|
+.save-bar { position: fixed; bottom: 0; left: 0; right: 0; background: #fff; padding: 20rpx 30rpx; padding-bottom: calc(20rpx + env(safe-area-inset-bottom)); }
|
|
|
+.save-btn { width: 100%; background: #F97316; color: #fff; border-radius: 44rpx; height: 84rpx; line-height: 84rpx; font-size: 32rpx; }
|
|
|
+.save-btn[disabled] { opacity: 0.6; }
|
|
|
+</style>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:注册到 pages.json**
|
|
|
+
|
|
|
+在 `pages.json` 的 `"root": "pages/health"` 分包中,`health-plan-summary` 条目之后添加:
|
|
|
+
|
|
|
+```json
|
|
|
+{
|
|
|
+ "path": "health-status-form",
|
|
|
+ "style": {
|
|
|
+ "navigationBarTitleText": "健康档案"
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 3:health-main/index.vue 加入口**
|
|
|
+
|
|
|
+在 `pages/health-main/index.vue` 中「智能健康方案」feat-card(`@click="goPlanSummary"`)之后添加:
|
|
|
+
|
|
|
+```html
|
|
|
+<view class="feat-card" @click="goPage('/pages/health/health-status-form')">
|
|
|
+ <text class="feat-icon">📋</text>
|
|
|
+ <text class="feat-title">健康档案</text>
|
|
|
+ <text class="feat-desc">身高体重·血压血糖·疾病史</text>
|
|
|
+</view>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 4:语法校验**
|
|
|
+
|
|
|
+运行(提取 `<script>` 块写入临时文件再 `node --check`,避免 ESM import 报错):
|
|
|
+
|
|
|
+```bash
|
|
|
+cd cfc-frontend
|
|
|
+sed -n '/<script>/,/<\/script>/p' pages/health/health-status-form.vue | sed '1d;$d' > /tmp/hs-form-script.js
|
|
|
+node --check /tmp/hs-form-script.js && rm -f /tmp/hs-form-script.js
|
|
|
+```
|
|
|
+
|
|
|
+预期:无输出(exit code 0)
|
|
|
+
|
|
|
+- [ ] **步骤 5:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-frontend/pages/health/health-status-form.vue cfc-frontend/pages.json cfc-frontend/pages/health-main/index.vue
|
|
|
+git commit -m "feat(frontend): 健康档案页 + 注册 + 健康主页入口"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 任务 8:出方案流程 health-plan-summary.vue 改造
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`cfc-frontend/pages/health/health-plan-summary.vue`
|
|
|
+
|
|
|
+> 当前结构:`onLoad`(187-195 行)→ `generatePlan`(246-272 行)→ `aiSendMessage({ query, reportId })`。改造点:onLoad 读取健康现状+饮食偏好、generatePlan 前弹窗提醒、传参。
|
|
|
+
|
|
|
+- [ ] **步骤 1:import 与 data 补充**
|
|
|
+
|
|
|
+将 `import { getFamilyReports, getFamilyMembers, aiSendMessage, createTask } from '../../utils/api'` 改为:
|
|
|
+
|
|
|
+```js
|
|
|
+import { getFamilyReports, getFamilyMembers, getHealthStatus, getDietPreferences, aiSendMessage, createTask } from '../../utils/api'
|
|
|
+import { parseDate } from '../../utils/format'
|
|
|
+```
|
|
|
+
|
|
|
+> `parseDate` 为项目既有工具(`utils/format.js`),用于解析 `updatedAt`(MySQL DATETIME 风格 `"2026-08-13 10:00:00"`),禁止直接 `new Date(string)`。
|
|
|
+
|
|
|
+在 data 的 `taskSubmittedCount: 0` 后添加:
|
|
|
+
|
|
|
+```js
|
|
|
+ healthStatus: null,
|
|
|
+ dietPrefs: null
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:onLoad 并行读取 + onShow 刷新**
|
|
|
+
|
|
|
+在 `onLoad` 中 `this.loadFamilyReports()` / `this.loadExecutors()` 之后添加:
|
|
|
+
|
|
|
+```js
|
|
|
+ this.loadHealthContext()
|
|
|
+```
|
|
|
+
|
|
|
+并新增方法(`loadHealthContext` 供 onLoad/onShow 复用):
|
|
|
+
|
|
|
+```js
|
|
|
+ loadHealthContext: function() {
|
|
|
+ var self = this
|
|
|
+ getHealthStatus().then(function(res) {
|
|
|
+ if (res && res.code === 200) {
|
|
|
+ self.healthStatus = res.data || null
|
|
|
+ }
|
|
|
+ }).catch(function() { self.healthStatus = null })
|
|
|
+ getDietPreferences().then(function(res) {
|
|
|
+ if (res && res.code === 200) {
|
|
|
+ self.dietPrefs = res.data || null
|
|
|
+ }
|
|
|
+ }).catch(function() { self.dietPrefs = null })
|
|
|
+ },
|
|
|
+```
|
|
|
+
|
|
|
+在 `onLoad` 之后(同生命周期区)添加 `onShow`,保证从档案页「去填写/去更新」返回后数据是最新的:
|
|
|
+
|
|
|
+```js
|
|
|
+ onShow: function() {
|
|
|
+ this.loadHealthContext()
|
|
|
+ },
|
|
|
+```
|
|
|
+
|
|
|
+> 注意:`onShow` 会在首次进入时先于/伴随 `onLoad` 触发,`loadHealthContext` 幂等(重复请求无害),无需去重。
|
|
|
+
|
|
|
+- [ ] **步骤 3:generatePlan 前弹窗提醒**
|
|
|
+
|
|
|
+将 `generatePlan` 方法开头改为(未填时弹窗,去填写/直接生成):
|
|
|
+
|
|
|
+```js
|
|
|
+ generatePlan: function() {
|
|
|
+ var self = this
|
|
|
+ if (!self.canGenerate) return
|
|
|
+ if (!self.healthStatus) {
|
|
|
+ uni.showModal({
|
|
|
+ title: '填写健康现状',
|
|
|
+ content: '尚未填写健康现状,方案可能不够精准。是否先填写?',
|
|
|
+ confirmText: '去填写',
|
|
|
+ cancelText: '直接生成',
|
|
|
+ success: function(res) {
|
|
|
+ if (res.confirm) {
|
|
|
+ uni.navigateTo({ url: '/pages/health/health-status-form' })
|
|
|
+ } else {
|
|
|
+ self.doGeneratePlan()
|
|
|
+ }
|
|
|
+ }
|
|
|
+ })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (self.isStatusStale()) {
|
|
|
+ uni.showModal({
|
|
|
+ title: '健康现状已较旧',
|
|
|
+ content: '上次填写时间较早,建议先更新健康现状以获得更精准方案。是否更新?',
|
|
|
+ confirmText: '去更新',
|
|
|
+ cancelText: '直接生成',
|
|
|
+ success: function(res) {
|
|
|
+ if (res.confirm) {
|
|
|
+ uni.navigateTo({ url: '/pages/health/health-status-form' })
|
|
|
+ } else {
|
|
|
+ self.doGeneratePlan()
|
|
|
+ }
|
|
|
+ }
|
|
|
+ })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ self.doGeneratePlan()
|
|
|
+ },
|
|
|
+ isStatusStale: function() {
|
|
|
+ var updatedAt = this.healthStatus && this.healthStatus.updatedAt
|
|
|
+ if (!updatedAt) return false
|
|
|
+ var d = parseDate(updatedAt)
|
|
|
+ if (!d) return false
|
|
|
+ var sixMonthsAgo = new Date()
|
|
|
+ sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6)
|
|
|
+ return d.getTime() < sixMonthsAgo.getTime()
|
|
|
+ },
|
|
|
+ doGeneratePlan: function() {
|
|
|
+ var self = this
|
|
|
+ self.loading = true
|
|
|
+ var reportIdsStr = self.selectedReportIds.join(',')
|
|
|
+ var sendData = { query: self.userGoal.trim(), reportId: reportIdsStr }
|
|
|
+ if (self.healthStatus) sendData.healthStatus = JSON.stringify(self.healthStatus)
|
|
|
+ if (self.dietPrefs) sendData.dietPrefs = JSON.stringify(self.dietPrefs)
|
|
|
+ aiSendMessage(sendData).then(function(res) {
|
|
|
+ self.loading = false
|
|
|
+ var answer = ''
|
|
|
+ if (res && res.answer) {
|
|
|
+ answer = res.answer
|
|
|
+ } else if (res && res.data && res.data.answer) {
|
|
|
+ answer = res.data.answer
|
|
|
+ } else if (res && typeof res === 'string') {
|
|
|
+ answer = res
|
|
|
+ }
|
|
|
+ if (answer) {
|
|
|
+ self.planContent = answer
|
|
|
+ self.parseTasksFromPlan(answer)
|
|
|
+ self.step = 3
|
|
|
+ } else {
|
|
|
+ uni.showToast({ title: '生成失败,请重试', icon: 'none' })
|
|
|
+ }
|
|
|
+ }).catch(function() {
|
|
|
+ self.loading = false
|
|
|
+ uni.showToast({ title: '请求失败,请检查网络', icon: 'none' })
|
|
|
+ })
|
|
|
+ },
|
|
|
+```
|
|
|
+
|
|
|
+> 原 `generatePlan` 的 AI 调用逻辑整体移入 `doGeneratePlan`,仅新增 `sendData` 组装与弹窗分支。`getDietPreferences` 后端按 `@RequestAttribute("familyMemberId")` 返回当前选中成员的偏好(沿用现有接口语义)。
|
|
|
+
|
|
|
+- [ ] **步骤 4:语法校验**
|
|
|
+
|
|
|
+运行(提取 `<script>` 块写入临时文件再 `node --check`,避免 ESM import 报错):
|
|
|
+
|
|
|
+```bash
|
|
|
+cd cfc-frontend
|
|
|
+sed -n '/<script>/,/<\/script>/p' pages/health/health-plan-summary.vue | sed '1d;$d' > /tmp/hs-plan-script.js
|
|
|
+node --check /tmp/hs-plan-script.js && rm -f /tmp/hs-plan-script.js
|
|
|
+```
|
|
|
+
|
|
|
+预期:无输出(exit code 0)
|
|
|
+
|
|
|
+- [ ] **步骤 5:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-frontend/pages/health/health-plan-summary.vue
|
|
|
+git commit -m "feat(frontend): 出方案流程读取健康现状+未填弹窗提醒+随请求传参"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 任务 9:整体验证
|
|
|
+
|
|
|
+**文件:** 无(验证任务)
|
|
|
+
|
|
|
+- [ ] **步骤 1:后端编译**
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn clean compile`
|
|
|
+预期:BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **步骤 2:检查路由重复**
|
|
|
+
|
|
|
+运行:`grep -rn '@Mapping' cfc-backend/src/main/java/com/etotem/cfc/controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u | grep -E 'health-status|health_status'`
|
|
|
+预期:仅 `/api/health-status/get`、`/api/health-status/save`
|
|
|
+
|
|
|
+- [ ] **步骤 3:前端语法校验**
|
|
|
+
|
|
|
+运行:对修改的 3 个 .vue 文件提取 `<script>` 块执行 `node --check`
|
|
|
+预期:全部无语法错误
|
|
|
+
|
|
|
+- [ ] **步骤 4:手工验证清单确认**
|
|
|
+
|
|
|
+1. 档案页首次填写 → 保存 → 重新进入回显
|
|
|
+2. 重复保存 → 仍只有一行记录(uk_user 保证)
|
|
|
+3. 未填状态点生成方案 → 弹窗出现 → 「直接生成」可继续
|
|
|
+4. 填写后点生成方案 → 不再弹窗 → AI 返回方案
|
|
|
+5. 校验非法值(身高 999、血压 abc)→ 拦截提示
|
|
|
+6. 后端日志确认 AIChatController 输出 healthStatus/dietPrefs 解析 warn(若传参正确则无 warn)
|
|
|
+
|
|
|
+- [ ] **步骤 5:最终 Commit(若有未提交改动)**
|
|
|
+
|
|
|
+```bash
|
|
|
+git status --short
|
|
|
+git add <遗漏文件>
|
|
|
+git commit -m "chore: 健康现状档案实现收尾"
|
|
|
+```
|