Bladeren bron

docs(plans): 当前状态调研健康现状档案实现计划

Xiaogang Liao 1 maand geleden
bovenliggende
commit
a0dd3f9593
1 gewijzigde bestanden met toevoegingen van 1091 en 0 verwijderingen
  1. 1091 0
      docs/superpowers/plans/2026-08-13-health-status-survey.md

+ 1091 - 0
docs/superpowers/plans/2026-08-13-health-status-survey.md

@@ -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: 健康现状档案实现收尾"
+```