2026-08-13-health-status-survey.md 40 KB

「当前状态调研」健康现状档案 实现计划

面向 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() 的闭合 } 之前添加:

// 迁移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 末尾追加:

-- 当前状态调研-健康现状档案(迁移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

    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 实体

    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

    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

    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 接口

    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:创建实现类

    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

    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

    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

    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 之后添加:

import com.fasterxml.jackson.databind.ObjectMapper;

在类内字段区(private TaskParseService taskParseService; 之后)添加:

private final ObjectMapper objectMapper = new ObjectMapper();
  • 步骤 2:在 sendMessage 中解析并注入

在 Map<String, Object> inputs; 与 if (reportId != null && surveyId != null) {...} 组装块之后、// 注入mascot信息到Dify inputs 之前,插入:

// 注入当前状态调研数据(健康现状 + 饮食偏好)
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

    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 行)附近添加:

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 行):

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 } } 替换为:

  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

    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。

<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 条目之后添加:

{
  "path": "health-status-form",
  "style": {
    "navigationBarTitleText": "健康档案"
  }
}
  • 步骤 3:health-main/index.vue 加入口

在 pages/health-main/index.vue 中「智能健康方案」feat-card(@click="goPlanSummary")之后添加:

<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 报错):

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

    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' 改为:

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 后添加:

      healthStatus: null,
      dietPrefs: null
  • 步骤 2:onLoad 并行读取 + onShow 刷新

在 onLoad 中 this.loadFamilyReports() / this.loadExecutors() 之后添加:

    this.loadHealthContext()

并新增方法(loadHealthContext 供 onLoad/onShow 复用):

    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,保证从档案页「去填写/去更新」返回后数据是最新的:

  onShow: function() {
    this.loadHealthContext()
  },

注意:onShow 会在首次进入时先于/伴随 onLoad 触发,loadHealthContext 幂等(重复请求无害),无需去重。

  • 步骤 3:generatePlan 前弹窗提醒

将 generatePlan 方法开头改为(未填时弹窗,去填写/直接生成):

    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 报错):

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

    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(若有未提交改动)

    git status --short
    git add <遗漏文件>
    git commit -m "chore: 健康现状档案实现收尾"