Browse Source

feat(health): 健康自检问卷向导与LITE轻享会员等级:问卷组件与方案页重写、能量沙盘问卷解锁、LangGraph健康上下文注入、成员级偏好读写安全校验、无特殊偏好标记、多页面联动调整

Sisyphus 1 week ago
parent
commit
fc15581eb4
30 changed files with 2253 additions and 472 deletions
  1. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  2. 2 2
      cfc-backend/src/main/java/com/etotem/cfc/controller/EnergyController.java
  3. 10 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthStatusController.java
  4. 46 7
      cfc-backend/src/main/java/com/etotem/cfc/controller/diet/DietPreferencesController.java
  5. 1 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/DietPreferencesDTO.java
  6. 1 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/DietPreferences.java
  7. 2 0
      cfc-backend/src/main/java/com/etotem/cfc/service/DietPreferencesService.java
  8. 11 0
      cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java
  9. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthStatusService.java
  10. 14 1
      cfc-backend/src/main/java/com/etotem/cfc/service/UnlockGateService.java
  11. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthStatusServiceImpl.java
  12. 2 12
      cfc-frontend/pages/action-detail/components/DimensionSubDims.vue
  13. 1 1
      cfc-frontend/pages/assessment/execution.vue
  14. 1 7
      cfc-frontend/pages/discover-detail/product-detail/product-detail.vue
  15. 329 0
      cfc-frontend/pages/health/components/BasicVitalsStep.vue
  16. 264 0
      cfc-frontend/pages/health/components/ConfirmStep.vue
  17. 447 0
      cfc-frontend/pages/health/components/DietHabitsStep.vue
  18. 338 0
      cfc-frontend/pages/health/components/HealthQuestionnaireWizard.vue
  19. 520 0
      cfc-frontend/pages/health/components/MedicalHistoryStep.vue
  20. 151 398
      cfc-frontend/pages/health/health-plan-summary.vue
  21. 1 4
      cfc-frontend/pages/health/my-feedback.vue
  22. 4 4
      cfc-frontend/pages/home-pages/parent-index.vue
  23. 6 9
      cfc-frontend/pages/index-home/index.vue
  24. 6 1
      cfc-frontend/pages/teacher/plan-list.vue
  25. 2 2
      cfc-frontend/pages/teacher/service-tasks.vue
  26. 1 11
      cfc-frontend/pages/vendor/team-customers/team-customers.vue
  27. 2 12
      cfc-frontend/pages/wealth/components/DimensionSubDims.vue
  28. 10 1
      cfc-frontend/pages/wisdom-detail/cognitive-report.vue
  29. 51 0
      cfc-langgraph/app/api/adapter.py
  30. 9 0
      cfc-langgraph/app/tools/java_client.py

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -3041,6 +3041,15 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
 
         // 迁移365: diet_preferences 添加无特殊偏好标记(问卷第3步「无特殊偏好」一键跳过落库)
         ensureColumn("diet_preferences", "no_preference", "TINYINT(1) DEFAULT 0 COMMENT '无特殊偏好标记(1=用户明确选择无特殊偏好)'");
+
+        // 迁移366: 新增 LITE 轻享会员等级(9.9元/月,解锁健康自检问卷与个性化方案)
+        try {
+            jdbcTemplate.execute("INSERT IGNORE INTO membership_levels (level_code, level_name, level_desc, price_monthly, price_quarterly, price_yearly, max_children, max_tasks_per_day, ai_review_enabled, priority_support, teacher_consultation) VALUES " +
+                    "('LITE', '轻享会员', '9.9元/月,解锁健康自检问卷与个性化方案', 990, 2970, 11880, 2, 10, 1, 0, 0)");
+            log.info("迁移366: 已插入 LITE 轻享会员等级");
+        } catch (Exception e) {
+            log.warn("迁移366: 插入 LITE 会员等级失败或已存在: {}", e.getMessage());
+        }
     }
 
     private void migrateHealthDimensionsTable() {

+ 2 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/EnergyController.java

@@ -144,8 +144,8 @@ public class EnergyController {
         Map<String, Object> m = new HashMap<>();
         m.put("id", null);
         m.put("gateType", "SELF_CHECK");
-        m.put("name", "五维基础能量检测");
-        m.put("description", "检查家庭的能量维度值,解锁能量沙盘");
+        m.put("name", "健康自检问卷");
+        m.put("description", "完成健康自检问卷,解锁能量沙盘");
         m.put("icon", "📊");
         return m;
     }

+ 10 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthStatusController.java

@@ -34,6 +34,16 @@ public class HealthStatusController {
                 : healthStatusService.getByUserId(userId));
     }
 
+    @Operation(summary = "内部:按成员查询健康现状(供 LangGraph 方案生成,无用户归属校验)", hidden = true)
+    @PostMapping("/internal/get")
+    public Result<HealthStatus> internalGet(@RequestBody(required = false) Map<String, Object> params) {
+        Long memberId = params != null ? ParamUtils.getLong(params.get("memberId")) : null;
+        if (memberId == null) {
+            return Result.error("缺少参数: memberId");
+        }
+        return Result.success(healthStatusService.getByMemberIdOnly(memberId));
+    }
+
     @Operation(summary = "保存/更新当前账号健康现状(memberId 为空存账号级,非空存成员级)")
     @PostMapping("/save")
     public Result<HealthStatus> save(

+ 46 - 7
cfc-backend/src/main/java/com/etotem/cfc/controller/diet/DietPreferencesController.java

@@ -3,9 +3,11 @@ package com.etotem.cfc.controller.diet;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.dto.DietPreferencesDTO;
 import com.etotem.cfc.service.DietPreferencesService;
+import com.etotem.cfc.service.FamilyMemberService;
 import org.springframework.web.bind.annotation.*;
 
 import javax.annotation.Resource;
+import java.util.Map;
 
 @RestController
 @RequestMapping("/api/diet/preferences")
@@ -14,22 +16,59 @@ public class DietPreferencesController {
     @Resource
     private DietPreferencesService dietPreferencesService;
 
+    @Resource
+    private FamilyMemberService familyMemberService;
+
     @PostMapping("/current-member")
     public Result<DietPreferencesDTO> getCurrentMemberPreferences(
-            @RequestAttribute(value = "familyMemberId", required = false) Long familyMemberId) {
-        if (familyMemberId == null) {
+            @RequestAttribute(value = "familyId", required = false) Long familyId,
+            @RequestAttribute(value = "familyMemberId", required = false) Long currentMemberId,
+            @RequestBody(required = false) Map<String, Object> params) {
+        // 显式 memberId(须同家庭)优先,未传时回退登录账号本人
+        Long memberId = resolveMemberId(params != null ? params.get("memberId") : null, familyId, currentMemberId);
+        if (memberId == null) {
             return Result.success(null);
         }
-        DietPreferencesDTO dto = dietPreferencesService.getCurrentMemberPreferences(familyMemberId);
-        return Result.success(dto);
+        return Result.success(dietPreferencesService.getCurrentMemberPreferences(memberId));
     }
 
     @PostMapping("/save")
     public Result<Void> savePreferences(
-            @RequestAttribute("familyMemberId") Long familyMemberId,
+            @RequestAttribute(value = "familyId", required = false) Long familyId,
+            @RequestAttribute(value = "familyMemberId", required = false) Long currentMemberId,
             @RequestBody DietPreferencesDTO dto) {
-        dto.setFamilyMemberId(familyMemberId);
-        dietPreferencesService.savePreferences(familyMemberId, dto);
+        // 显式 familyMemberId(须同家庭)优先,未传时回退登录账号本人
+        Long memberId = resolveMemberId(dto.getFamilyMemberId(), familyId, currentMemberId);
+        if (memberId == null) {
+            return Result.error("未指定成员");
+        }
+        dto.setFamilyMemberId(memberId);
+        dietPreferencesService.savePreferences(memberId, dto);
         return Result.success(null);
     }
+
+    /**
+     * 解析目标成员:显式 memberId(须与登录用户同家庭)优先,否则回退登录成员。
+     */
+    private Long resolveMemberId(Object requestedRaw, Long familyId, Long currentMemberId) {
+        Long requested = toLong(requestedRaw);
+        if (requested != null && familyMemberService.isMemberInFamily(familyId, requested)) {
+            return requested;
+        }
+        return currentMemberId;
+    }
+
+    private Long toLong(Object raw) {
+        if (raw == null) {
+            return null;
+        }
+        if (raw instanceof Number) {
+            return ((Number) raw).longValue();
+        }
+        try {
+            return Long.valueOf(String.valueOf(raw).trim());
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
 }

+ 1 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/DietPreferencesDTO.java

@@ -18,4 +18,5 @@ public class DietPreferencesDTO {
     private String goalSource;
     private Integer goalConfirmed;
     private String filledAt;
+    private Boolean noPreference;
 }

+ 1 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/DietPreferences.java

@@ -24,6 +24,7 @@ public class DietPreferences implements Serializable {
     private String healthGoals;
     private String goalSource;
     private Integer goalConfirmed;
+    private Boolean noPreference;
     private Date filledAt;
     private Date updatedAt;
 }

+ 2 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/DietPreferencesService.java

@@ -56,6 +56,7 @@ public class DietPreferencesService {
         entity.setHealthGoals(toJSON(dto.getHealthGoals()));
         entity.setGoalSource(dto.getGoalSource());
         entity.setGoalConfirmed(dto.getGoalConfirmed() != null ? dto.getGoalConfirmed() : 0);
+        entity.setNoPreference(dto.getNoPreference() != null ? dto.getNoPreference() : false);
         
         if (entity.getId() == null) {
             dietPreferencesMapper.insert(entity);
@@ -87,6 +88,7 @@ public class DietPreferencesService {
         dto.setHealthGoals(fromJSON(entity.getHealthGoals()));
         dto.setGoalSource(entity.getGoalSource());
         dto.setGoalConfirmed(entity.getGoalConfirmed());
+        dto.setNoPreference(entity.getNoPreference());
         dto.setFilledAt(entity.getFilledAt() != null ? entity.getFilledAt().toString() : null);
         return dto;
     }

+ 11 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java

@@ -76,6 +76,17 @@ public class FamilyMemberService {
     @Resource
     private MembershipService membershipService;
 
+    /**
+     * 校验成员是否属于指定家庭(用于避免跨家庭读写成员数据)
+     */
+    public boolean isMemberInFamily(Long familyId, Long memberId) {
+        if (familyId == null || memberId == null) {
+            return false;
+        }
+        FamilyMember member = familyMemberMapper.selectById(memberId);
+        return member != null && familyId.equals(member.getFamilyId());
+    }
+
     /**
      * 添加家庭成员
      */

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthStatusService.java

@@ -13,6 +13,9 @@ public interface HealthStatusService {
     /** 查询指定家庭成员的健康现状(memberId 为空时退化为账号级) */
     HealthStatus getByMemberId(Long userId, Long memberId);
 
+    /** 内部:仅按 memberId 查询健康现状(供 LangGraph 方案生成,不做用户归属校验) */
+    HealthStatus getByMemberIdOnly(Long memberId);
+
     /** 按成员保存/更新健康现状(memberId 为空时退化为账号级) */
     HealthStatus saveForMember(Long userId, Long memberId, HealthStatus status);
 }

+ 14 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/UnlockGateService.java

@@ -45,6 +45,9 @@ public class UnlockGateService {
     @Resource
     private HealthReportMapper healthReportMapper;
 
+    @Resource
+    private HealthStatusMapper healthStatusMapper;
+
     @Resource
     private ProductOrderMapper productOrderMapper;
 
@@ -165,7 +168,17 @@ public class UnlockGateService {
                 new LambdaQueryWrapper<FiveDimensionScore>()
                         .eq(FiveDimensionScore::getFamilyId, familyId)
                         .gt(FiveDimensionScore::getScore, 0));
-        return scoreCount >= 1;
+        if (scoreCount >= 1) return true;
+        // 来源3:健康自检问卷(health_status)——任一家庭成员完成问卷即解锁
+        List<FamilyMember> familyMembers = familyMemberMapper.selectList(
+                new LambdaQueryWrapper<FamilyMember>().eq(FamilyMember::getFamilyId, familyId));
+        if (!familyMembers.isEmpty()) {
+            List<Long> memberIds = familyMembers.stream().map(FamilyMember::getId).collect(Collectors.toList());
+            long healthCount = healthStatusMapper.selectCount(
+                    new LambdaQueryWrapper<HealthStatus>().in(HealthStatus::getMemberId, memberIds));
+            if (healthCount >= 1) return true;
+        }
+        return false;
     }
 
     // ========== 当前锁定关卡 ==========

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthStatusServiceImpl.java

@@ -39,6 +39,15 @@ public class HealthStatusServiceImpl implements HealthStatusService {
                         .eq(HealthStatus::getMemberId, memberId));
     }
 
+    @Override
+    public HealthStatus getByMemberIdOnly(Long memberId) {
+        if (memberId == null) return null;
+        return healthStatusMapper.selectOne(
+                new LambdaQueryWrapper<HealthStatus>()
+                        .eq(HealthStatus::getMemberId, memberId)
+                        .last("LIMIT 1"));
+    }
+
     @Override
     public HealthStatus saveForMember(Long userId, Long memberId, HealthStatus status) {
         if (userId == null) throw new IllegalArgumentException("userId不能为空");

+ 2 - 12
cfc-frontend/pages/action-detail/components/DimensionSubDims.vue

@@ -19,11 +19,11 @@
           <view class="dim-item-score-wrap">
             <text
               class="dim-item-score"
-              :class="getScoreClass(d.score)"
+              :class="d.score >= 80 ? 'score-good' : (d.score >= 60 ? 'score-ok' : 'score-low')"
             >{{ d.score }}</text>
             <text
               class="dim-item-level"
-              :class="'level-' + getScoreLevel(d.score)"
+              :class="'level-' + (d.score >= 80 ? 'good' : (d.score >= 60 ? 'ok' : 'low'))"
             >{{ getScoreLevelText(d.score) }}</text>
           </view>
         </view>
@@ -48,21 +48,11 @@ export default {
     dims: { type: Array, default: () => [] }
   },
   methods: {
-    getScoreLevel(score) {
-      if (score >= 80) return 'good'
-      if (score >= 60) return 'ok'
-      return 'low'
-    },
     getScoreLevelText(score) {
       if (score >= 80) return '优秀'
       if (score >= 60) return '良好'
       if (score > 0) return '待提升'
       return '-'
-    },
-    getScoreClass(score) {
-      if (score >= 80) return 'score-good'
-      if (score >= 60) return 'score-ok'
-      return 'score-low'
     }
   }
 }

+ 1 - 1
cfc-frontend/pages/assessment/execution.vue

@@ -15,7 +15,7 @@
     <view v-else-if="execution" class="execution-detail">
       <!-- 状态卡片 -->
       <view class="status-card">
-        <view class="status-icon" :class="getStatusClass(execution)">
+        <view class="status-icon" :class="execution.userConfirmed === 1 ? 'completed' : (execution.assessorStatus === 'completed' && (!execution.plannerId || execution.plannerStatus === 'completed') ? 'ready' : 'pending')">
           <view class="icon-circle"></view>
           <view v-if="getStatusClass(execution) === 'completed'" class="icon-check"></view>
           <view v-else-if="getStatusClass(execution) === 'ready'" class="icon-alert"></view>

+ 1 - 7
cfc-frontend/pages/discover-detail/product-detail/product-detail.vue

@@ -70,7 +70,7 @@
             <view
               v-for="option in group.options"
               :key="option.id"
-              :class="getSpecOptionClass(group.groupId, option)"
+              :class="selectedSpecs[group.groupId] === option.id ? 'spec-option spec-option-selected' : (!option.enabled || (option.stock !== undefined && option.stock <= 0) ? 'spec-option spec-option-disabled' : 'spec-option')"
               @click="selectSpec(group.groupId, option)"
             >
               <text class="spec-option-text">{{ option.name }}</text>
@@ -521,12 +521,6 @@ export default {
       return desc
     },
 
-    getSpecOptionClass: function(groupId, option) {
-      if (this.selectedSpecs[groupId] === option.id) return 'spec-option spec-option-selected'
-      if (!option.enabled || (option.stock !== undefined && option.stock <= 0)) return 'spec-option spec-option-disabled'
-      return 'spec-option'
-    },
-
     goUpgrade: function() {
       uni.navigateTo({ url: '/pages/membership/upgrade' })
     },

+ 329 - 0
cfc-frontend/pages/health/components/BasicVitalsStep.vue

@@ -0,0 +1,329 @@
+<template>
+  <view class="vitals-step">
+    <view class="step-title-row">
+      <text class="step-title">基础体征</text>
+      <text class="step-sub">填写身高、体重、血压、血糖等基础指标</text>
+    </view>
+
+    <!-- 身高 -->
+    <view class="form-card">
+      <view class="card-head">
+        <text class="card-title">身高 (cm)</text>
+        <view class="quick-chips">
+          <view
+            class="quick-chip"
+            :class="{ 'quick-chip-on': heightQuick === 'normal' }"
+            @click="setHeightQuick('normal')"
+          ><text>正常</text></view>
+          <view
+            class="quick-chip"
+            :class="{ 'quick-chip-on': heightQuick === 'high' }"
+            @click="setHeightQuick('high')"
+          ><text>偏高</text></view>
+          <view
+            class="quick-chip"
+            :class="{ 'quick-chip-on': heightQuick === 'low' }"
+            @click="setHeightQuick('low')"
+          ><text>偏低</text></view>
+        </view>
+      </view>
+      <input class="num-input" type="digit" v-model="inner.heightCm" placeholder="如 170"
+             @input="onInput" />
+      <text class="unit-hint">单位:厘米</text>
+    </view>
+
+    <!-- 体重 -->
+    <view class="form-card">
+      <view class="card-head">
+        <text class="card-title">体重 (kg)</text>
+        <view class="quick-chips">
+          <view
+            class="quick-chip"
+            :class="{ 'quick-chip-on': weightQuick === 'normal' }"
+            @click="setWeightQuick('normal')"
+          ><text>正常</text></view>
+          <view
+            class="quick-chip"
+            :class="{ 'quick-chip-on': weightQuick === 'high' }"
+            @click="setWeightQuick('high')"
+          ><text>偏重</text></view>
+          <view
+            class="quick-chip"
+            :class="{ 'quick-chip-on': weightQuick === 'low' }"
+            @click="setWeightQuick('low')"
+          ><text>偏轻</text></view>
+        </view>
+      </view>
+      <input class="num-input" type="digit" v-model="inner.weightKg" placeholder="如 60"
+             @input="onInput" />
+      <text class="unit-hint">单位:公斤</text>
+    </view>
+
+    <!-- 血压 -->
+    <view class="form-card">
+      <view class="card-head">
+        <text class="card-title">血压</text>
+        <view class="quick-chips">
+          <view
+            class="quick-chip"
+            :class="{ 'quick-chip-on': bpQuick === 'normal' }"
+            @click="setBpQuick('normal')"
+          ><text>正常</text></view>
+          <view
+            class="quick-chip"
+            :class="{ 'quick-chip-on': bpQuick === 'high' }"
+            @click="setBpQuick('high')"
+          ><text>偏高</text></view>
+          <view
+            class="quick-chip"
+            :class="{ 'quick-chip-on': bpQuick === 'low' }"
+            @click="setBpQuick('low')"
+          ><text>偏低</text></view>
+        </view>
+      </view>
+      <input class="num-input" type="text" v-model="inner.bloodPressure" placeholder="如 120/80"
+             @input="onInput" />
+      <text class="unit-hint">格式:收缩压/舒张压 (mmHg)</text>
+    </view>
+
+    <!-- 血糖 -->
+    <view class="form-card">
+      <view class="card-head">
+        <text class="card-title">空腹血糖 (mmol/L)</text>
+        <view class="quick-chips">
+          <view
+            class="quick-chip"
+            :class="{ 'quick-chip-on': glucoseQuick === 'normal' }"
+            @click="setGlucoseQuick('normal')"
+          ><text>正常</text></view>
+          <view
+            class="quick-chip"
+            :class="{ 'quick-chip-on': glucoseQuick === 'high' }"
+            @click="setGlucoseQuick('high')"
+          ><text>偏高</text></view>
+          <view
+            class="quick-chip"
+            :class="{ 'quick-chip-on': glucoseQuick === 'low' }"
+            @click="setGlucoseQuick('low')"
+          ><text>偏低</text></view>
+        </view>
+      </view>
+      <input class="num-input" type="digit" v-model="inner.bloodGlucose" placeholder="如 5.6"
+             @input="onInput" />
+    </view>
+
+    <!-- 血脂 -->
+    <view class="form-card">
+      <view class="card-head">
+        <text class="card-title">总胆固醇 (mmol/L)</text>
+        <view class="quick-chips">
+          <view
+            class="quick-chip"
+            :class="{ 'quick-chip-on': lipidQuick === 'normal' }"
+            @click="setLipidQuick('normal')"
+          ><text>正常</text></view>
+          <view
+            class="quick-chip"
+            :class="{ 'quick-chip-on': lipidQuick === 'high' }"
+            @click="setLipidQuick('high')"
+          ><text>偏高</text></view>
+        </view>
+      </view>
+      <input class="num-input" type="digit" v-model="inner.lipidCholesterol" placeholder="如 4.5"
+             @input="onInput" />
+    </view>
+
+    <!-- 其他补充 -->
+    <view class="form-card">
+      <view class="card-head">
+        <text class="card-title">其他补充(选填)</text>
+      </view>
+      <textarea class="notes-area" v-model="inner.notes" placeholder="其他想补充的健康信息"
+                :maxlength="500" @input="onInput" />
+    </view>
+  </view>
+</template>
+
+<script>
+// 快捷标签 → 参考数值(近似参考值,点击后填入输入框,用户可继续手改)
+var QUICK_VALUES = {
+  height: { normal: '170', high: '185', low: '155' },
+  weight: { normal: '60', high: '80', low: '45' },
+  bloodPressure: { normal: '120/80', high: '140/90', low: '90/60' },
+  bloodGlucose: { normal: '5.0', high: '7.0', low: '3.5' },
+  lipid: { normal: '4.5', high: '6.2' }
+}
+
+function matchQuick(map, val) {
+  if (val === '' || val === null || val === undefined) return ''
+  var v = String(val)
+  for (var k in map) {
+    if (Object.prototype.hasOwnProperty.call(map, k) && map[k] === v) return k
+  }
+  return ''
+}
+
+export default {
+  name: 'BasicVitalsStep',
+  props: {
+    value: {
+      type: Object,
+      default: function() { return {} }
+    }
+  },
+  data() {
+    return {
+      inner: {
+        heightCm: '',
+        weightKg: '',
+        bloodPressure: '',
+        bloodGlucose: '',
+        lipidCholesterol: '',
+        notes: ''
+      }
+    }
+  },
+  computed: {
+    heightQuick: function() { return matchQuick(QUICK_VALUES.height, this.inner.heightCm) },
+    weightQuick: function() { return matchQuick(QUICK_VALUES.weight, this.inner.weightKg) },
+    bpQuick: function() { return matchQuick(QUICK_VALUES.bloodPressure, this.inner.bloodPressure) },
+    glucoseQuick: function() { return matchQuick(QUICK_VALUES.bloodGlucose, this.inner.bloodGlucose) },
+    lipidQuick: function() { return matchQuick(QUICK_VALUES.lipid, this.inner.lipidCholesterol) }
+  },
+  watch: {
+    value: {
+      deep: true,
+      handler: function(val) {
+        this.syncFromValue(val)
+      }
+    }
+  },
+  created: function() {
+    this.syncFromValue(this.value)
+  },
+  methods: {
+    syncFromValue: function(val) {
+      if (!val) return
+      this.inner.heightCm = val.heightCm != null ? String(val.heightCm) : ''
+      this.inner.weightKg = val.weightKg != null ? String(val.weightKg) : ''
+      this.inner.bloodPressure = val.bloodPressure != null ? String(val.bloodPressure) : ''
+      this.inner.bloodGlucose = val.bloodGlucose != null ? String(val.bloodGlucose) : ''
+      this.inner.lipidCholesterol = val.lipidCholesterol != null ? String(val.lipidCholesterol) : ''
+      this.inner.notes = val.notes != null ? String(val.notes) : ''
+    },
+    onInput: function() {
+      var payload = {
+        heightCm: this.inner.heightCm,
+        weightKg: this.inner.weightKg,
+        bloodPressure: this.inner.bloodPressure,
+        bloodGlucose: this.inner.bloodGlucose,
+        lipidCholesterol: this.inner.lipidCholesterol,
+        notes: this.inner.notes
+      }
+      this.$emit('input', payload)
+    },
+    // 快捷选择:点击标签把对应参考数值填入输入框(用户可继续手改)
+    setHeightQuick: function(k) {
+      this.inner.heightCm = QUICK_VALUES.height[k] || ''
+      this.onInput()
+    },
+    setWeightQuick: function(k) {
+      this.inner.weightKg = QUICK_VALUES.weight[k] || ''
+      this.onInput()
+    },
+    setBpQuick: function(k) {
+      this.inner.bloodPressure = QUICK_VALUES.bloodPressure[k] || ''
+      this.onInput()
+    },
+    setGlucoseQuick: function(k) {
+      this.inner.bloodGlucose = QUICK_VALUES.bloodGlucose[k] || ''
+      this.onInput()
+    },
+    setLipidQuick: function(k) {
+      this.inner.lipidCholesterol = QUICK_VALUES.lipid[k] || ''
+      this.onInput()
+    }
+  }
+}
+</script>
+
+<style scoped>
+.vitals-step {
+  padding: 10rpx 0 30rpx;
+}
+.step-title-row {
+  margin-bottom: 20rpx;
+}
+.step-title {
+  font-size: 34rpx;
+  font-weight: 700;
+  color: #333;
+}
+.step-sub {
+  display: block;
+  font-size: 24rpx;
+  color: #999;
+  margin-top: 8rpx;
+}
+.form-card {
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 24rpx;
+  margin-bottom: 20rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
+}
+.card-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 16rpx;
+}
+.card-title {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #333;
+}
+.quick-chips {
+  display: flex;
+  flex-wrap: wrap;
+}
+.quick-chip {
+  padding: 8rpx 20rpx;
+  border-radius: 999rpx;
+  background: #F1F5F9;
+  font-size: 22rpx;
+  color: #64748B;
+  margin-left: 10rpx;
+  margin-bottom: 6rpx;
+}
+.quick-chip-on {
+  background: #FFF3E0;
+  color: #F97316;
+  font-weight: 600;
+}
+.num-input {
+  height: 80rpx;
+  border: 2rpx solid #E2E8F0;
+  border-radius: 12rpx;
+  padding: 0 20rpx;
+  font-size: 28rpx;
+  color: #333;
+  background: #F8FAFC;
+}
+.unit-hint {
+  display: block;
+  font-size: 22rpx;
+  color: #999;
+  margin-top: 10rpx;
+}
+.notes-area {
+  width: 100%;
+  min-height: 160rpx;
+  background: #F8FAFC;
+  border: 2rpx solid #E2E8F0;
+  border-radius: 12rpx;
+  padding: 16rpx;
+  font-size: 26rpx;
+  color: #333;
+}
+</style>

+ 264 - 0
cfc-frontend/pages/health/components/ConfirmStep.vue

@@ -0,0 +1,264 @@
+<template>
+  <view class="confirm-step">
+    <view class="step-title-row">
+      <text class="step-title">确认信息</text>
+      <text class="step-sub">核对无误后保存并生成健康方案</text>
+    </view>
+
+    <!-- 基础体征 -->
+    <view class="form-card">
+      <view class="card-head">
+        <text class="card-title">基础体征</text>
+      </view>
+      <view class="preview-row" v-if="healthStatus.heightCm">
+        <text class="preview-label">身高</text>
+        <text class="preview-value">{{ healthStatus.heightCm }} cm</text>
+      </view>
+      <view class="preview-row" v-if="healthStatus.weightKg">
+        <text class="preview-label">体重</text>
+        <text class="preview-value">{{ healthStatus.weightKg }} kg</text>
+      </view>
+      <view class="preview-row" v-if="healthStatus.bloodPressure">
+        <text class="preview-label">血压</text>
+        <text class="preview-value">{{ healthStatus.bloodPressure }}</text>
+      </view>
+      <view class="preview-row" v-if="healthStatus.bloodGlucose">
+        <text class="preview-label">空腹血糖</text>
+        <text class="preview-value">{{ healthStatus.bloodGlucose }} mmol/L</text>
+      </view>
+      <view class="preview-row" v-if="healthStatus.lipidCholesterol">
+        <text class="preview-label">总胆固醇</text>
+        <text class="preview-value">{{ healthStatus.lipidCholesterol }} mmol/L</text>
+      </view>
+    </view>
+
+    <!-- 疾病史 -->
+    <view class="form-card" v-if="healthStatus.selectedDiseases && healthStatus.selectedDiseases.length > 0">
+      <view class="card-head">
+        <text class="card-title">疾病史</text>
+      </view>
+      <view class="tag-wrap">
+        <view v-for="(d, i) in healthStatus.selectedDiseases" :key="getKey(d.name, i)" class="tag-item">
+          <text>{{ d.name }}</text>
+          <text v-if="d.diagnosedAt" class="tag-date">{{ d.diagnosedAt }}</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 用药 -->
+    <view class="form-card" v-if="hasMeds">
+      <view class="card-head">
+        <text class="card-title">在服药物/治疗</text>
+      </view>
+      <view v-for="(med, i) in healthStatus.medRows" :key="getKey(med.name, i)" v-if="med.name" class="med-line">
+        <text class="med-name">{{ med.name }}</text>
+        <text v-if="med.durationText" class="med-duration">{{ med.durationText }}</text>
+      </view>
+    </view>
+
+    <!-- 饮食习惯 -->
+    <view class="form-card">
+      <view class="card-head">
+        <text class="card-title">饮食习惯</text>
+      </view>
+      <view class="preview-row" v-if="dietPreferences.allergies && dietPreferences.allergies.length > 0">
+        <text class="preview-label">过敏原</text>
+        <text class="preview-value">{{ dietPreferences.allergies.join('、') }}</text>
+      </view>
+      <view class="preview-row" v-if="dietPreferences.absoluteAvoid && dietPreferences.absoluteAvoid.length > 0">
+        <text class="preview-label">绝对忌口</text>
+        <text class="preview-value">{{ dietPreferences.absoluteAvoid.join('、') }}</text>
+      </view>
+      <view class="preview-row" v-if="dietPreferences.mustEat && dietPreferences.mustEat.length > 0">
+        <text class="preview-label">必吃</text>
+        <text class="preview-value">{{ dietPreferences.mustEat.join('、') }}</text>
+      </view>
+      <view class="preview-row" v-if="dietPreferences.flavorPref && dietPreferences.flavorPref.length > 0">
+        <text class="preview-label">口味</text>
+        <text class="preview-value">{{ dietPreferences.flavorPref.join('、') }}</text>
+      </view>
+      <view class="preview-row" v-if="dietPreferences.cuisinePref && dietPreferences.cuisinePref.length > 0">
+        <text class="preview-label">菜系</text>
+        <text class="preview-value">{{ dietPreferences.cuisinePref.join('、') }}</text>
+      </view>
+      <view class="preview-row" v-if="dietPreferences.healthGoals && dietPreferences.healthGoals.length > 0">
+        <text class="preview-label">健康目标</text>
+        <text class="preview-value">{{ dietPreferences.healthGoals.join('、') }}</text>
+      </view>
+      <text class="empty-hint" v-if="!hasDiet && noPreference">无特殊偏好</text>
+      <text class="empty-hint" v-else-if="!hasDiet">未填写饮食习惯</text>
+    </view>
+
+    <!-- 保存并生成 -->
+    <view class="btn-pair">
+      <button class="btn-prev" @click="goBack">上一步</button>
+      <button class="btn-generate" :disabled="submitting" @click="onSubmit">
+        {{ submitting ? '保存中...' : '保存并生成方案' }}
+      </button>
+    </view>
+  </view>
+</template>
+
+<script>
+export default {
+  name: 'ConfirmStep',
+  props: {
+    healthStatus: {
+      type: Object,
+      default: function() { return {} }
+    },
+    dietPreferences: {
+      type: Object,
+      default: function() { return {} }
+    },
+    submitting: {
+      type: Boolean,
+      default: false
+    }
+  },
+  computed: {
+    hasMeds: function() {
+      var rows = this.healthStatus.medRows || []
+      for (var i = 0; i < rows.length; i++) {
+        if (rows[i] && rows[i].name) return true
+      }
+      return false
+    },
+    hasDiet: function() {
+      var d = this.dietPreferences
+      return !!(d &&
+        ((d.allergies && d.allergies.length > 0) ||
+        (d.absoluteAvoid && d.absoluteAvoid.length > 0) ||
+        (d.mustEat && d.mustEat.length > 0) ||
+        (d.flavorPref && d.flavorPref.length > 0) ||
+        (d.healthGoals && d.healthGoals.length > 0)))
+    },
+    noPreference: function() {
+      return !!(this.dietPreferences && this.dietPreferences.noPreference)
+    }
+  },
+  methods: {
+    getKey: function(name, i) {
+      return 'k' + (name || '') + i
+    },
+    goBack: function() {
+      this.$emit('back')
+    },
+    onSubmit: function() {
+      this.$emit('submit', {
+        healthStatus: this.healthStatus,
+        dietPreferences: this.dietPreferences
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.confirm-step {
+  padding: 10rpx 0 30rpx;
+}
+.step-title-row {
+  margin-bottom: 20rpx;
+}
+.step-title {
+  font-size: 34rpx;
+  font-weight: 700;
+  color: #333;
+}
+.step-sub {
+  display: block;
+  font-size: 24rpx;
+  color: #999;
+  margin-top: 8rpx;
+}
+.form-card {
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 24rpx;
+  margin-bottom: 20rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
+}
+.card-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 16rpx;
+}
+.card-title {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #333;
+}
+.preview-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 12rpx 0;
+  border-bottom: 2rpx solid #F8FAFC;
+}
+.preview-label {
+  font-size: 26rpx;
+  color: #64748B;
+}
+.preview-value {
+  font-size: 26rpx;
+  color: #334155;
+  font-weight: 500;
+}
+.tag-wrap {
+  display: flex;
+  flex-wrap: wrap;
+}
+.tag-item {
+  padding: 10rpx 20rpx;
+  border-radius: 999rpx;
+  background: #F1F5F9;
+  font-size: 24rpx;
+  color: #334155;
+  margin: 0 14rpx 14rpx 0;
+}
+.tag-date {
+  color: #94A3B8;
+  margin-left: 8rpx;
+}
+.med-line {
+  display: flex;
+  justify-content: space-between;
+  padding: 10rpx 0;
+}
+.med-name {
+  font-size: 26rpx;
+  color: #334155;
+}
+.med-duration {
+  font-size: 24rpx;
+  color: #94A3B8;
+}
+.empty-hint {
+  display: block;
+  font-size: 24rpx;
+  color: #94A3B8;
+  padding: 10rpx 0;
+}
+.btn-pair {
+  display: flex;
+  padding: 20rpx 0;
+}
+.btn-prev,
+.btn-generate {
+  flex: 1;
+  margin: 0 10rpx;
+  border-radius: 999rpx;
+  font-size: 30rpx;
+  font-weight: 600;
+}
+.btn-prev {
+  background: #F1F5F9;
+  color: #334155;
+}
+.btn-generate {
+  background: linear-gradient(135deg, #F97316, #FB923C);
+  color: #fff;
+}
+</style>

+ 447 - 0
cfc-frontend/pages/health/components/DietHabitsStep.vue

@@ -0,0 +1,447 @@
+<template>
+  <view class="diet-step">
+    <view class="step-title-row">
+      <text class="step-title">饮食习惯</text>
+      <text class="step-sub">忌口与偏好,选得越细,方案越贴合</text>
+    </view>
+
+    <!-- 无特殊偏好:一键清空本节并直接进入确认步 -->
+    <view class="pref-none-row">
+      <button
+        class="pref-none-btn"
+        :class="{ 'pref-none-btn-active': inner.noPreference }"
+        @click="setNoPreference"
+      >{{ inner.noPreference ? '已选:无特殊偏好' : '无特殊偏好,直接下一步' }}</button>
+    </view>
+
+    <!-- 过敏原 -->
+    <view class="form-card">
+      <view class="card-head">
+        <text class="card-title">过敏原</text>
+        <text class="card-hint">多选</text>
+      </view>
+      <view class="tag-wrap">
+        <view
+          v-for="(item, i) in presetAllergies"
+          :key="item"
+          class="tag-item"
+          :class="{ 'tag-active': inner.allergies.indexOf(item) >= 0 }"
+          @click="toggleChip('allergies', item)"
+        >
+          <text>{{ item }}</text>
+        </view>
+      </view>
+      <view class="custom-row">
+        <input class="custom-input" type="text" v-model="customInput" placeholder="输入其他过敏原"
+               confirm-type="done" @confirm="addCustom('allergies')" />
+        <text class="add-link" @click="addCustom('allergies')">添加</text>
+      </view>
+    </view>
+
+    <!-- 绝对忌口 -->
+    <view class="form-card">
+      <view class="card-head">
+        <text class="card-title">绝对忌口</text>
+        <text class="card-hint">多选</text>
+      </view>
+      <view class="tag-wrap">
+        <view
+          v-for="(item, i) in presetAvoid"
+          :key="item"
+          class="tag-item"
+          :class="{ 'tag-active': inner.absoluteAvoid.indexOf(item) >= 0 }"
+          @click="toggleChip('absoluteAvoid', item)"
+        >
+          <text>{{ item }}</text>
+        </view>
+      </view>
+      <view class="custom-row">
+        <input class="custom-input" type="text" v-model="customInput" placeholder="输入其他忌口"
+               confirm-type="done" @confirm="addCustom('absoluteAvoid')" />
+        <text class="add-link" @click="addCustom('absoluteAvoid')">添加</text>
+      </view>
+    </view>
+
+    <!-- 必吃 -->
+    <view class="form-card">
+      <view class="card-head">
+        <text class="card-title">必吃食物</text>
+        <text class="card-hint">多选</text>
+      </view>
+      <view class="tag-wrap">
+        <view
+          v-for="(item, i) in presetMustEat"
+          :key="item"
+          class="tag-item"
+          :class="{ 'tag-active': inner.mustEat.indexOf(item) >= 0 }"
+          @click="toggleChip('mustEat', item)"
+        >
+          <text>{{ item }}</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 宗教饮食 -->
+    <view class="form-card">
+      <view class="card-head">
+        <text class="card-title">宗教饮食</text>
+      </view>
+      <view class="tag-wrap">
+        <view
+          v-for="(opt, i) in religiousOptions"
+          :key="opt.value"
+          class="tag-item"
+          :class="{ 'tag-active': inner.religiousDiet === opt.value }"
+          @click="setReligious(opt.value)"
+        >
+          <text>{{ opt.label }}</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 辣度 -->
+    <view class="form-card">
+      <view class="card-head">
+        <text class="card-title">辣度偏好</text>
+      </view>
+      <view class="slider-row">
+        <text class="slider-label">不辣</text>
+        <slider class="slider" min="0" max="5" :value="inner.spiceLevel || 0"
+                @change="onSpiceChange" show-value />
+        <text class="slider-label">特辣</text>
+      </view>
+      <text class="spice-label">当前:{{ spiceLabels[inner.spiceLevel] || '不辣' }}</text>
+    </view>
+
+    <!-- 口味 -->
+    <view class="form-card">
+      <view class="card-head">
+        <text class="card-title">口味偏好</text>
+        <text class="card-hint">多选</text>
+      </view>
+      <view class="tag-wrap">
+        <view
+          v-for="(item, i) in presetFlavor"
+          :key="item"
+          class="tag-item"
+          :class="{ 'tag-active': inner.flavorPref.indexOf(item) >= 0 }"
+          @click="toggleChip('flavorPref', item)"
+        >
+          <text>{{ item }}</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 菜系 -->
+    <view class="form-card">
+      <view class="card-head">
+        <text class="card-title">菜系偏好</text>
+        <text class="card-hint">多选</text>
+      </view>
+      <view class="tag-wrap">
+        <view
+          v-for="(item, i) in presetCuisine"
+          :key="item"
+          class="tag-item"
+          :class="{ 'tag-active': inner.cuisinePref.indexOf(item) >= 0 }"
+          @click="toggleChip('cuisinePref', item)"
+        >
+          <text>{{ item }}</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 烹饪方式 -->
+    <view class="form-card">
+      <view class="card-head">
+        <text class="card-title">烹饪方式</text>
+        <text class="card-hint">多选</text>
+      </view>
+      <view class="tag-wrap">
+        <view
+          v-for="(item, i) in presetCooking"
+          :key="item"
+          class="tag-item"
+          :class="{ 'tag-active': inner.cookingMethods.indexOf(item) >= 0 }"
+          @click="toggleChip('cookingMethods', item)"
+        >
+          <text>{{ item }}</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 健康目标 -->
+    <view class="form-card">
+      <view class="card-head">
+        <text class="card-title">健康目标</text>
+        <text class="card-hint">多选</text>
+      </view>
+      <view class="tag-wrap">
+        <view
+          v-for="(item, i) in presetGoals"
+          :key="item"
+          class="tag-item"
+          :class="{ 'tag-active': inner.healthGoals.indexOf(item) >= 0 }"
+          @click="toggleChip('healthGoals', item)"
+        >
+          <text>{{ item }}</text>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+// 预设选项(与 pages/diet/preferences.vue 保持一致,后端 diet_preferences 表字段对齐)
+var PRESET_ALLERGIES = ['海鲜', '花生', '乳糖', '鸡蛋', '牛奶', '豆类', '麸质', '坚果', '芒果', '其他']
+var PRESET_AVOID = ['牛肉', '羊肉', '猪肉', '内脏', '油腻', '辛辣', '生冷', '腌制']
+var PRESET_FLAVOR = ['酸', '甜', '咸', '鲜', '苦', '清淡', '浓郁']
+var PRESET_CUISINE = ['川', '粤', '鲁', '淮扬', '日料', '西餐', '东南亚', '韩餐']
+var PRESET_COOKING = ['蒸', '煮', '炒', '烤', '炖', '凉拌', '煎', '炸']
+var PRESET_MUSTEAT = ['蔬菜', '水果', '粗粮', '鱼类', '鸡蛋', '豆制品', '菌菇']
+var PRESET_GOALS = ['减脂', '增肌', '控糖', '肠道调理', '增强免疫']
+
+export default {
+  name: 'DietHabitsStep',
+  props: {
+    value: {
+      type: Object,
+      default: function() { return {} }
+    }
+  },
+  data() {
+    return {
+      inner: {
+        allergies: [],
+        absoluteAvoid: [],
+        mustEat: [],
+        religiousDiet: 'none',
+        spiceLevel: 0,
+        flavorPref: [],
+        cuisinePref: [],
+        cookingMethods: [],
+        healthGoals: [],
+        noPreference: false
+      },
+      customInput: '',
+      spiceLabels: ['不辣', '微辣', '中辣', '偏辣', '很辣', '特辣']
+    }
+  },
+  computed: {
+    presetAllergies: function() { return PRESET_ALLERGIES },
+    presetAvoid: function() { return PRESET_AVOID },
+    presetFlavor: function() { return PRESET_FLAVOR },
+    presetCuisine: function() { return PRESET_CUISINE },
+    presetCooking: function() { return PRESET_COOKING },
+    presetMustEat: function() { return PRESET_MUSTEAT },
+    presetGoals: function() { return PRESET_GOALS },
+    religiousOptions: function() {
+      return [
+        { value: 'none', label: '无限制' },
+        { value: 'halal', label: '清真' },
+        { value: 'vegetarian', label: '素食' },
+        { value: 'vegan', label: '纯素' }
+      ]
+    }
+  },
+  watch: {
+    value: {
+      deep: true,
+      handler: function(val) {
+        this.syncFromValue(val)
+      }
+    }
+  },
+  created: function() {
+    this.syncFromValue(this.value)
+  },
+  methods: {
+    syncFromValue: function(val) {
+      if (!val) return
+      var self = this
+      var fields = ['allergies', 'absoluteAvoid', 'mustEat', 'flavorPref', 'cuisinePref', 'cookingMethods', 'healthGoals']
+      fields.forEach(function(f) {
+        self.inner[f] = Array.isArray(val[f]) ? val[f].slice() : []
+      })
+      self.inner.religiousDiet = val.religiousDiet || 'none'
+      self.inner.spiceLevel = val.spiceLevel != null ? val.spiceLevel : 0
+      self.inner.noPreference = !!val.noPreference
+    },
+    emitInput: function() {
+      this.$emit('input', {
+        allergies: this.inner.allergies.slice(),
+        absoluteAvoid: this.inner.absoluteAvoid.slice(),
+        mustEat: this.inner.mustEat.slice(),
+        religiousDiet: this.inner.religiousDiet,
+        spiceLevel: this.inner.spiceLevel,
+        flavorPref: this.inner.flavorPref.slice(),
+        cuisinePref: this.inner.cuisinePref.slice(),
+        cookingMethods: this.inner.cookingMethods.slice(),
+        healthGoals: this.inner.healthGoals.slice(),
+        noPreference: this.inner.noPreference
+      })
+    },
+    toggleChip: function(field, value) {
+      var list = this.inner[field]
+      if (!list) return
+      var idx = list.indexOf(value)
+      if (idx >= 0) {
+        this.inner[field] = list.slice(0, idx).concat(list.slice(idx + 1))
+      } else {
+        this.inner[field] = list.concat([value])
+      }
+      this.emitInput()
+    },
+    addCustom: function(field) {
+      var value = (this.customInput || '').trim()
+      if (!value) return
+      var list = this.inner[field] || []
+      if (list.indexOf(value) < 0) {
+        this.inner[field] = list.concat([value])
+        this.emitInput()
+      }
+      this.customInput = ''
+    },
+    setReligious: function(value) {
+      this.inner.religiousDiet = value
+      this.emitInput()
+    },
+    onSpiceChange: function(e) {
+      this.inner.spiceLevel = e.detail.value
+      this.emitInput()
+    },
+    // 「无特殊偏好」:清空本节并标记,随后通知父级直接进入确认步
+    setNoPreference: function() {
+      this.inner.allergies = []
+      this.inner.absoluteAvoid = []
+      this.inner.mustEat = []
+      this.inner.religiousDiet = 'none'
+      this.inner.spiceLevel = 0
+      this.inner.flavorPref = []
+      this.inner.cuisinePref = []
+      this.inner.cookingMethods = []
+      this.inner.healthGoals = []
+      this.inner.noPreference = true
+      this.emitInput()
+      this.$emit('skip-no-preference')
+    }
+  }
+}
+</script>
+
+<style scoped>
+.diet-step {
+  padding: 10rpx 0 30rpx;
+}
+.step-title-row {
+  margin-bottom: 20rpx;
+}
+.step-title {
+  font-size: 34rpx;
+  font-weight: 700;
+  color: #333;
+}
+.step-sub {
+  display: block;
+  font-size: 24rpx;
+  color: #999;
+  margin-top: 8rpx;
+}
+.pref-none-row {
+  margin-bottom: 20rpx;
+}
+.pref-none-btn {
+  width: 100%;
+  padding: 20rpx 0;
+  border-radius: 16rpx;
+  background: #FFF7ED;
+  color: #F97316;
+  font-size: 28rpx;
+  font-weight: 600;
+  border: 2rpx solid #FED7AA;
+  line-height: 1.4;
+}
+.pref-none-btn-active {
+  background: #F97316;
+  color: #fff;
+  border-color: #F97316;
+}
+.form-card {
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 24rpx;
+  margin-bottom: 20rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
+}
+.card-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 16rpx;
+}
+.card-title {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #333;
+}
+.card-hint {
+  font-size: 22rpx;
+  color: #94A3B8;
+}
+.tag-wrap {
+  display: flex;
+  flex-wrap: wrap;
+}
+.tag-item {
+  padding: 12rpx 24rpx;
+  border-radius: 999rpx;
+  background: #F1F5F9;
+  font-size: 24rpx;
+  color: #334155;
+  margin: 0 14rpx 14rpx 0;
+}
+.tag-active {
+  background: #FFF3E0;
+  color: #F97316;
+  font-weight: 600;
+}
+.custom-row {
+  display: flex;
+  align-items: center;
+  margin-top: 10rpx;
+}
+.custom-input {
+  flex: 1;
+  border: 2rpx solid #E2E8F0;
+  border-radius: 12rpx;
+  padding: 14rpx 20rpx;
+  font-size: 26rpx;
+  background: #F8FAFC;
+}
+.add-link {
+  color: #F97316;
+  font-size: 26rpx;
+  font-weight: 600;
+  padding: 14rpx 10rpx;
+  margin-left: 10rpx;
+}
+.slider-row {
+  display: flex;
+  align-items: center;
+}
+.slider-label {
+  font-size: 24rpx;
+  color: #94A3B8;
+  width: 72rpx;
+}
+.slider {
+  flex: 1;
+}
+.spice-label {
+  display: block;
+  font-size: 24rpx;
+  color: #F97316;
+  text-align: center;
+  margin-top: 8rpx;
+}
+</style>

+ 338 - 0
cfc-frontend/pages/health/components/HealthQuestionnaireWizard.vue

@@ -0,0 +1,338 @@
+<template>
+  <view class="wizard-wrap">
+    <!-- 内部步骤指示器(4 步:基础体征/疾病史/饮食习惯/确认) -->
+    <view class="wizard-steps">
+      <view
+        v-for="(s, i) in wizardSteps"
+        :key="i"
+        class="wizard-step"
+        :class="{ 'wizard-step-active': currentStep === i + 1, 'wizard-step-done': currentStep > i + 1 }"
+        @click="goToStep(i + 1)"
+      >
+        <text class="wizard-step-num">{{ i + 1 }}</text>
+        <text class="wizard-step-label">{{ s }}</text>
+      </view>
+    </view>
+
+    <!-- 步骤内容 -->
+    <view class="wizard-body">
+      <!-- Step 1: 基础体征 -->
+      <view v-if="currentStep === 1">
+        <basic-vitals-step
+          ref="stepRef"
+          :value="currentMemberForm"
+          @input="onVitalsInput"
+        />
+      </view>
+
+      <!-- Step 2: 疾病史与用药 -->
+      <view v-if="currentStep === 2">
+        <medical-history-step
+          ref="stepRef"
+          :value="currentMemberForm"
+          @input="onMedicalInput"
+        />
+      </view>
+
+      <!-- Step 3: 饮食习惯 -->
+      <view v-if="currentStep === 3">
+        <diet-habits-step
+          ref="stepRef"
+          :value="currentMemberDiet"
+          @input="onDietInput"
+          @skip-no-preference="onDietSkipNoPreference"
+        />
+      </view>
+
+      <!-- Step 4: 确认 -->
+      <view v-if="currentStep === 4">
+        <confirm-step
+          ref="stepRef"
+          :health-status="currentMemberForm"
+          :diet-preferences="currentMemberDiet"
+          :submitting="submitting"
+          @submit="handleSubmit"
+          @back="prev"
+        />
+      </view>
+    </view>
+
+    <!-- 底部导航:上一步 / 下一步(最后一步为保存并生成) -->
+    <view class="wizard-footer" v-if="currentStep < 4">
+      <button class="btn-prev" :disabled="currentStep === 1" @click="prev">
+        <text>上一步</text>
+      </button>
+      <button class="btn-next" :disabled="validating" @click="next">
+        <text>{{ validating ? '校验中...' : '下一步' }}</text>
+      </button>
+    </view>
+  </view>
+</template>
+
+<script>
+import BasicVitalsStep from './BasicVitalsStep.vue'
+import MedicalHistoryStep from './MedicalHistoryStep.vue'
+import DietHabitsStep from './DietHabitsStep.vue'
+import ConfirmStep from './ConfirmStep.vue'
+
+export default {
+  name: 'HealthQuestionnaireWizard',
+  components: {
+    BasicVitalsStep,
+    MedicalHistoryStep,
+    DietHabitsStep,
+    ConfirmStep
+  },
+  props: {
+    // 成员表单快照:memberId → { form, lipidCholesterol, selectedDiseases, medRows }
+    memberFormMap: {
+      type: Object,
+      default: function() { return {} }
+    },
+    // 成员饮食习惯快照:memberId → dietPreferences 对象
+    dietMap: {
+      type: Object,
+      default: function() { return {} }
+    },
+    // 当前填写成员
+    activeFormMemberId: {
+      type: String,
+      default: ''
+    },
+    // 提交中(由父组件 loading 驱动,失败后按钮可恢复)
+    submitting: {
+      type: Boolean,
+      default: false
+    }
+  },
+  data() {
+    return {
+      currentStep: 1,
+      validating: false
+    }
+  },
+  computed: {
+    wizardSteps: function() {
+      return ['基础体征', '疾病史', '饮食习惯', '确认']
+    },
+    // 当前成员的健康表单(来自父组件快照)
+    currentMemberForm: function() {
+      var map = this.memberFormMap || {}
+      var key = this.activeFormMemberId || '__account'
+      var snapshot = map[key] || {}
+      return {
+        heightCm: snapshot.form && snapshot.form.heightCm || '',
+        weightKg: snapshot.form && snapshot.form.weightKg || '',
+        bloodPressure: snapshot.form && snapshot.form.bloodPressure || '',
+        bloodGlucose: snapshot.form && snapshot.form.bloodGlucose || '',
+        notes: snapshot.form && snapshot.form.notes || '',
+        lipidCholesterol: snapshot.lipidCholesterol || '',
+        selectedDiseases: snapshot.selectedDiseases || [],
+        medRows: snapshot.medRows || [],
+        noMedication: !!snapshot.noMedication
+      }
+    },
+    // 当前成员的饮食习惯
+    currentMemberDiet: function() {
+      var map = this.dietMap || {}
+      var key = this.activeFormMemberId || '__account'
+      return map[key] || {}
+    }
+  },
+  watch: {
+    // 切换填写成员时回到第 1 步(各成员数据快照已保留,不影响已填内容)
+    activeFormMemberId: function() {
+      this.currentStep = 1
+    }
+  },
+  methods: {
+    // —— 步骤导航 ——
+    goToStep: function(n) {
+      if (n < 1 || n > 4) return
+      if (n <= this.currentStep) {
+        // 回退到已完成步骤,无需校验
+        this.currentStep = n
+        return
+      }
+      // 向前跳需逐级校验中间每一步(保证"每步都校验")
+      for (var s = this.currentStep; s < n; s++) {
+        if (!this.validateStep(s)) return
+      }
+      this.currentStep = n
+    },
+    prev: function() {
+      if (this.currentStep > 1) {
+        this.currentStep -= 1
+      }
+    },
+    next: function() {
+      var pass = this.validateStep(this.currentStep)
+      if (!pass) return
+      if (this.currentStep < 4) {
+        this.currentStep += 1
+      }
+    },
+    // —— 每步校验(用户在确认需求时明确要求"每步都校验") ——
+    validateStep: function(step) {
+      var self = this
+      var validatorMap = {
+        1: this.validateVitals,
+        2: this.validateMedical,
+        3: this.validateDiet
+      }
+      var fn = validatorMap[step]
+      if (fn) {
+        var msg = fn.call(this)
+        if (msg) {
+          uni.showToast({ title: msg, icon: 'none' })
+          return false
+        }
+      }
+      return true
+    },
+    // Step1: 体征数值合法性
+    validateVitals: function() {
+      var form = this.currentMemberForm
+      var height = String(form.heightCm || '').trim()
+      var weight = String(form.weightKg || '').trim()
+      var bp = String(form.bloodPressure || '').trim()
+      var glucose = String(form.bloodGlucose || '').trim()
+      if (height && !/^\d{2,3}(\.\d+)?$/.test(height)) {
+        return '身高格式不正确'
+      }
+      if (weight && !/^\d{1,3}(\.\d+)?$/.test(weight)) {
+        return '体重格式不正确'
+      }
+      if (bp && !/^\d{2,3}\/\d{2,3}$/.test(bp)) {
+        return '血压格式应为 120/80'
+      }
+      if (glucose && !/^\d{1,2}(\.\d+)?$/.test(glucose)) {
+        return '血糖格式不正确'
+      }
+      return ''
+    },
+    // Step2: 疾病史至少填写一项,或已标记"无长期用药"
+    validateMedical: function() {
+      var snapshot = this.currentMemberForm
+      if (snapshot.noMedication) return ''
+      var diseases = snapshot.selectedDiseases || []
+      var meds = snapshot.medRows || []
+      var hasMed = meds.some(function(m) { return m && m.name && String(m.name).trim() })
+      if (diseases.length === 0 && !hasMed) {
+        return '请至少填写一项疾病史或药物,如无请勾选"无长期用药"'
+      }
+      return ''
+    },
+    // Step3: 饮食习惯至少填写一项(忌口/偏好/必吃/菜系/烹饪/辣度/宗教饮食/目标任一)
+    validateDiet: function() {
+      var diet = this.currentMemberDiet || {}
+      // 已明确选择「无特殊偏好」→ 直接通过
+      if (diet.noPreference) return ''
+      var arrays = [diet.allergies, diet.absoluteAvoid, diet.mustEat, diet.flavorPref, diet.cuisinePref, diet.cookingMethods, diet.healthGoals]
+      var hasAny = arrays.some(function(a) { return a && a.length > 0 })
+      if (!hasAny && !(diet.spiceLevel && Number(diet.spiceLevel) > 0) && !(diet.religiousDiet && diet.religiousDiet !== 'none')) {
+        return '请至少选择一项饮食习惯偏好'
+      }
+      return ''
+    },
+    // —— 子组件数据同步回父组件 ——
+    onVitalsInput: function(payload) {
+      this.$emit('update-vitals', payload)
+    },
+    onMedicalInput: function(payload) {
+      this.$emit('update-medical', payload)
+    },
+    onDietInput: function(payload) {
+      this.$emit('update-diet', payload)
+    },
+    // 第3步「无特殊偏好」:跳过校验直接进入确认步
+    onDietSkipNoPreference: function() {
+      this.currentStep = 4
+    },
+    // —— 确认步骤提交:聚合所有成员数据后交给父组件统一保存+生成 ——
+    handleSubmit: function() {
+      var self = this
+      this.$emit('submit', {
+        memberFormMap: self.memberFormMap,
+        dietMap: self.dietMap,
+        activeFormMemberId: self.activeFormMemberId
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.wizard-wrap {
+  display: flex;
+  flex-direction: column;
+  min-height: 600rpx;
+}
+.wizard-steps {
+  display: flex;
+  justify-content: space-between;
+  padding: 20rpx 10rpx 10rpx;
+  background: #fff;
+}
+.wizard-step {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  flex: 1;
+}
+.wizard-step-num {
+  width: 44rpx;
+  height: 44rpx;
+  border-radius: 22rpx;
+  background: #e0e0e0;
+  color: #999;
+  font-size: 24rpx;
+  font-weight: bold;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.wizard-step-active .wizard-step-num {
+  background: #F97316;
+  color: #fff;
+}
+.wizard-step-done .wizard-step-num {
+  background: #4CAF50;
+  color: #fff;
+}
+.wizard-step-label {
+  font-size: 20rpx;
+  color: #999;
+  white-space: nowrap;
+  margin-top: 6rpx;
+}
+.wizard-step-active .wizard-step-label,
+.wizard-step-done .wizard-step-label {
+  color: #333;
+  font-weight: 500;
+}
+.wizard-body {
+  flex: 1;
+  padding: 10rpx 20rpx;
+}
+.wizard-footer {
+  display: flex;
+  padding: 20rpx 20rpx 30rpx;
+}
+.btn-prev,
+.btn-next {
+  flex: 1;
+  margin: 0 10rpx;
+  border-radius: 999rpx;
+  font-size: 30rpx;
+  font-weight: 600;
+}
+.btn-prev {
+  background: #F1F5F9;
+  color: #334155;
+}
+.btn-next {
+  background: linear-gradient(135deg, #F97316, #FB923C);
+  color: #fff;
+}
+</style>

+ 520 - 0
cfc-frontend/pages/health/components/MedicalHistoryStep.vue

@@ -0,0 +1,520 @@
+<template>
+  <view class="medical-step">
+    <view class="step-title-row">
+      <text class="step-title">疾病史与用药</text>
+      <text class="step-sub">选择过往疾病与当前用药,可选填</text>
+    </view>
+
+    <!-- 疾病史 -->
+    <view class="form-card">
+      <view class="card-head">
+        <text class="card-title">疾病史</text>
+        <text class="card-count">已选 {{ inner.selectedDiseases.length }} 项</text>
+      </view>
+
+      <view v-for="(group, gi) in diseaseGroups" :key="group.key" class="disease-group">
+        <view class="group-head" @click="toggleGroup(group.key)">
+          <text class="group-title">{{ group.title }}</text>
+          <text class="group-arrow">{{ expandedGroups.indexOf(group.key) >= 0 ? '收起' : '展开' }}</text>
+        </view>
+        <view v-if="expandedGroups.indexOf(group.key) >= 0" class="tag-wrap">
+          <view
+            v-for="(item, ii) in group.items"
+            :key="item.name"
+            class="tag-item"
+            :class="{ 'tag-active': selectedNames.indexOf(item.name) >= 0 }"
+            @click="toggleDisease(item.name)"
+          >
+            <text>{{ item.name }}</text>
+            <text v-if="item.risk" class="risk-badge">★</text>
+          </view>
+        </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 v-if="inner.selectedDiseases.length > 0" class="selected-area">
+        <view class="selected-title">已选疾病</view>
+        <view v-for="(item, si) in inner.selectedDiseases" :key="getSelKey(si)" class="sel-row">
+          <text class="sel-name">{{ item.name }}</text>
+          <picker mode="date" fields="month" :value="item.diagnosedAt || currentMonth" :end="monthEnd" @change="onDiagChange(si, $event)">
+            <view class="sel-diag">{{ item.diagnosedAt ? item.diagnosedAt : '设确诊时间' }}</view>
+          </picker>
+          <text class="sel-del" @click="removeSelected(si)">×</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 在服药物/治疗 -->
+    <view class="form-card">
+      <view class="card-head">
+        <text class="card-title">在服药物/治疗</text>
+        <text class="card-count">{{ inner.medRows.length }} 条</text>
+      </view>
+      <view v-for="(med, idx) in inner.medRows" :key="getMedKey(idx)" class="med-row">
+        <view class="med-line">
+          <input class="med-name" type="text" v-model="med.name" placeholder="药物品牌,如 立普妥"
+                 @input="emitInput" />
+          <text class="med-del" @click="removeMed(idx)">删除</text>
+        </view>
+        <view class="med-line">
+          <picker mode="date" fields="month" :value="med.startDate || currentMonth" :end="monthEnd" @change="onMedStartChange(idx, $event)">
+            <view class="med-picker">{{ med.startDate ? med.startDate : '选择开始日期' }}</view>
+          </picker>
+          <text class="med-forget" @click="onForgetClick(idx)">记不清</text>
+        </view>
+        <view class="med-line" v-if="med.durationText">
+          <text class="med-duration">已服时长:{{ med.durationText }}</text>
+        </view>
+        <view class="med-line">
+          <input class="med-time" type="text" v-model="med.time" placeholder="服药时间,如 早饭后一次"
+                 @input="emitInput" />
+        </view>
+      </view>
+      <text class="add-link" @click="addMed">+ 添加药物/治疗</text>
+    </view>
+
+    <!-- 快捷:无长期用药 -->
+    <view class="form-card">
+      <view
+        class="quick-chip"
+        :class="{ 'quick-chip-on': noMedication }"
+        @click="toggleNoMedication"
+      >
+        <text>{{ noMedication ? '✓ 已选' : '无长期用药' }}</text>
+      </view>
+      <text class="unit-hint" v-if="noMedication">已标记无长期用药,可跳过药物填写</text>
+    </view>
+  </view>
+</template>
+
+<script>
+// 预置疾病清单(与 health-plan-summary.vue 保持一致的 8 组)
+var DISEASE_GROUPS = [
+  { key: 'cardio', title: '心血管代谢', items: [
+    { name: '高血压', risk: false },
+    { name: '糖尿病', risk: false },
+    { name: '血脂异常', risk: false },
+    { name: '冠心病', risk: false },
+    { name: '脑卒中', risk: false },
+    { name: '心力衰竭', risk: false },
+    { name: '心房颤动', risk: false },
+    { name: '慢阻肺', risk: false },
+    { name: '心脑血管疾病', risk: true },
+    { name: 'II型糖尿病', risk: true },
+    { name: '肥胖', risk: true },
+    { name: '超重', risk: false },
+    { name: '高尿酸血症', risk: false },
+    { name: '骨质疏松', risk: false }
+  ]},
+  { key: 'digest', title: '消化系统', items: [
+    { name: '幽门螺杆菌感染', risk: false },
+    { name: '脂肪肝', risk: false },
+    { name: '慢性胃炎', risk: false },
+    { name: '胃食管反流', risk: false },
+    { name: '肠易激综合征', risk: true },
+    { name: '炎症性肠炎', risk: true },
+    { name: '慢性便秘', risk: true },
+    { name: '感染性腹泻', risk: true },
+    { name: '胆结石', risk: false },
+    { name: '炎症性肠病', risk: false },
+    { name: '肝病', risk: true },
+    { name: '胃病', risk: true },
+    { name: '胆病', risk: true },
+    { name: '肾病', risk: true },
+    { name: '腹胀', risk: true }
+  ]},
+  { key: 'immune', title: '过敏免疫', items: [
+    { name: '过敏性鼻炎', risk: true },
+    { name: '哮喘', risk: false },
+    { name: '湿疹', risk: false },
+    { name: '食物过敏', risk: false },
+    { name: '自体免疫病', risk: true }
+  ]},
+  { key: 'neuro', title: '精神神经', items: [
+    { name: '抑郁症', risk: true },
+    { name: '失眠', risk: true },
+    { name: '焦虑障碍', risk: false },
+    { name: '偏头痛', risk: false },
+    { name: '自闭症', risk: true }
+  ]},
+  { key: 'resp', title: '呼吸系统', items: [
+    { name: '肺部感染或疾病', risk: true },
+    { name: '肺部疾病', risk: true }
+  ]},
+  { key: 'tumor', title: '肿瘤', items: [
+    { name: '结直肠癌', risk: true },
+    { name: '肺癌', risk: false },
+    { name: '甲状腺癌', risk: false },
+    { name: '肝癌', risk: false },
+    { name: '胃癌', risk: false },
+    { name: '乳腺癌', risk: false },
+    { name: '食管癌', risk: false }
+  ]},
+  { key: 'child', title: '儿童青少年', items: [
+    { name: '近视', risk: false },
+    { name: '龋齿', risk: false },
+    { name: '注意缺陷多动障碍', risk: false },
+    { name: '甲状腺疾病', risk: true }
+  ]},
+  { key: 'other', title: '其他', items: [
+    { name: '缺铁性贫血', risk: false },
+    { name: '贫血', risk: false }
+  ]}
+]
+
+export default {
+  name: 'MedicalHistoryStep',
+  props: {
+    value: {
+      type: Object,
+      default: function() { return {} }
+    }
+  },
+  data() {
+    return {
+      diseaseGroups: DISEASE_GROUPS,
+      expandedGroups: [],
+      diseaseInput: '',
+      currentMonth: '',
+      monthEnd: ''
+    }
+  },
+  computed: {
+    inner: function() {
+      return this.value || {}
+    },
+    noMedication: function() {
+      return !!this.inner.noMedication
+    },
+    selectedNames: function() {
+      var list = this.inner.selectedDiseases || []
+      return list.map(function(d) { return d.name })
+    }
+  },
+  created: function() {
+    var now = new Date()
+    var y = now.getFullYear()
+    var m = now.getMonth() + 1
+    var mStr = m < 10 ? '0' + m : '' + m
+    this.currentMonth = y + '-' + mStr
+    var lastDay = new Date(y, m, 0).getDate()
+    this.monthEnd = y + '-' + mStr + '-' + (lastDay < 10 ? '0' + lastDay : '' + lastDay)
+    // 默认展开前 3 组
+    this.expandedGroups = DISEASE_GROUPS.slice(0, 3).map(function(g) { return g.key })
+    // 已有选中项时展开对应组
+    var self = this
+    ;(this.inner.selectedDiseases || []).forEach(function(d) {
+      var gk = self.findGroupKey(d.name)
+      if (gk && self.expandedGroups.indexOf(gk) < 0) self.expandedGroups.push(gk)
+    })
+  },
+  methods: {
+    emitInput: function() {
+      this.$emit('input', this.inner)
+    },
+    findGroupKey: function(name) {
+      for (var i = 0; i < DISEASE_GROUPS.length; i++) {
+        var items = DISEASE_GROUPS[i].items
+        for (var j = 0; j < items.length; j++) {
+          if (items[j].name === name) return DISEASE_GROUPS[i].key
+        }
+      }
+      return ''
+    },
+    toggleGroup: function(key) {
+      var idx = this.expandedGroups.indexOf(key)
+      if (idx >= 0) this.expandedGroups.splice(idx, 1)
+      else this.expandedGroups.push(key)
+    },
+    toggleDisease: function(name) {
+      var list = this.inner.selectedDiseases || []
+      var idx = -1
+      for (var i = 0; i < list.length; i++) {
+        if (list[i].name === name) { idx = i; break }
+      }
+      var next
+      if (idx >= 0) {
+        next = list.slice(0, idx).concat(list.slice(idx + 1))
+      } else {
+        next = list.concat([{ name: name, diagnosedAt: '', custom: false }])
+      }
+      this.$emit('input', Object.assign({}, this.inner, { selectedDiseases: next }))
+    },
+    addCustomDisease: function() {
+      var name = (this.diseaseInput || '').trim()
+      if (!name) return
+      var list = this.inner.selectedDiseases || []
+      var exists = list.some(function(d) { return d.name === name })
+      if (!exists) {
+        var next = list.concat([{ name: name, diagnosedAt: '', custom: true }])
+        this.$emit('input', Object.assign({}, this.inner, { selectedDiseases: next }))
+      }
+      this.diseaseInput = ''
+    },
+    getSelKey: function(i) { return 's' + i },
+    getMedKey: function(i) { return 'm' + i },
+    removeSelected: function(idx) {
+      var list = this.inner.selectedDiseases || []
+      var next = list.slice(0, idx).concat(list.slice(idx + 1))
+      this.$emit('input', Object.assign({}, this.inner, { selectedDiseases: next }))
+    },
+    onDiagChange: function(idx, e) {
+      var list = this.inner.selectedDiseases || []
+      var item = Object.assign({}, list[idx], { diagnosedAt: e.detail.value })
+      var next = list.slice()
+      next[idx] = item
+      this.$emit('input', Object.assign({}, this.inner, { selectedDiseases: next }))
+    },
+    addMed: function() {
+      var rows = (this.inner.medRows && this.inner.medRows.length > 0 ? this.inner.medRows.slice() : [])
+      rows.push({ name: '', startDate: '', durationText: '', time: '' })
+      this.$emit('input', Object.assign({}, this.inner, { medRows: rows }))
+    },
+    removeMed: function(idx) {
+      var rows = (this.inner.medRows || []).slice()
+      rows.splice(idx, 1)
+      this.$emit('input', Object.assign({}, this.inner, { medRows: rows }))
+    },
+    onMedStartChange: function(idx, e) {
+      var start = e.detail.value
+      var rows = (this.inner.medRows || []).slice()
+      rows[idx] = Object.assign({}, rows[idx], { startDate: start, durationText: this.calcDuration(start) })
+      this.$emit('input', Object.assign({}, this.inner, { medRows: rows }))
+    },
+    onForgetClick: function(idx) {
+      var self = this
+      uni.showModal({
+        title: '记不清开始日期',
+        editable: true,
+        placeholderText: '如:约 3 个月',
+        success: function(res) {
+          if (res.confirm && res.content) {
+            var rows = (self.inner.medRows || []).slice()
+            rows[idx] = Object.assign({}, rows[idx], { durationText: res.content.trim() })
+            self.$emit('input', Object.assign({}, self.inner, { medRows: rows }))
+          }
+        }
+      })
+    },
+    calcDuration: function(startDate) {
+      if (!startDate) return ''
+      var parts = startDate.split('-')
+      if (parts.length !== 2) return ''
+      var sy = parseInt(parts[0], 10)
+      var sm = parseInt(parts[1], 10)
+      var now = new Date()
+      var months = (now.getFullYear() - sy) * 12 + (now.getMonth() + 1 - sm)
+      if (months < 1) months = 1
+      if (months < 12) return '已服 ' + months + ' 个月'
+      return '已服 ' + Math.floor(months / 12) + ' 年'
+    },
+    toggleNoMedication: function() {
+      this.$emit('input', Object.assign({}, this.inner, { noMedication: !this.noMedication }))
+    }
+  }
+}
+</script>
+
+<style scoped>
+.medical-step {
+  padding: 10rpx 0 30rpx;
+}
+.step-title-row {
+  margin-bottom: 20rpx;
+}
+.step-title {
+  font-size: 34rpx;
+  font-weight: 700;
+  color: #333;
+}
+.step-sub {
+  display: block;
+  font-size: 24rpx;
+  color: #999;
+  margin-top: 8rpx;
+}
+.form-card {
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 24rpx;
+  margin-bottom: 20rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
+}
+.card-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 16rpx;
+}
+.card-title {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #333;
+}
+.card-count {
+  font-size: 22rpx;
+  color: #F97316;
+  background: #FFF3E0;
+  padding: 4rpx 16rpx;
+  border-radius: 20rpx;
+}
+.group-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 16rpx 0 8rpx;
+}
+.group-title {
+  font-size: 26rpx;
+  font-weight: 600;
+  color: #475569;
+}
+.group-arrow {
+  font-size: 22rpx;
+  color: #94A3B8;
+}
+.tag-wrap {
+  display: flex;
+  flex-wrap: wrap;
+}
+.tag-item {
+  padding: 12rpx 24rpx;
+  border-radius: 999rpx;
+  background: #F1F5F9;
+  font-size: 24rpx;
+  color: #334155;
+  margin: 0 14rpx 14rpx 0;
+  position: relative;
+}
+.tag-active {
+  background: #FFF3E0;
+  color: #F97316;
+  font-weight: 600;
+}
+.risk-badge {
+  color: #EF4444;
+  margin-left: 6rpx;
+}
+.custom-row {
+  display: flex;
+  align-items: center;
+  margin-top: 10rpx;
+}
+.custom-input {
+  flex: 1;
+  border: 2rpx solid #E2E8F0;
+  border-radius: 12rpx;
+  padding: 14rpx 20rpx;
+  font-size: 26rpx;
+  background: #F8FAFC;
+}
+.add-link {
+  color: #F97316;
+  font-size: 26rpx;
+  font-weight: 600;
+  padding: 14rpx 10rpx;
+  margin-left: 10rpx;
+}
+.selected-area {
+  margin-top: 16rpx;
+  border-top: 2rpx solid #F1F5F9;
+  padding-top: 16rpx;
+}
+.selected-title {
+  font-size: 24rpx;
+  color: #94A3B8;
+  margin-bottom: 10rpx;
+}
+.sel-row {
+  display: flex;
+  align-items: center;
+  margin-bottom: 12rpx;
+}
+.sel-name {
+  flex: 1;
+  font-size: 26rpx;
+  color: #333;
+}
+.sel-diag {
+  font-size: 22rpx;
+  color: #F97316;
+  background: #FFF3E0;
+  padding: 8rpx 20rpx;
+  border-radius: 999rpx;
+}
+.sel-del {
+  color: #EF4444;
+  font-size: 32rpx;
+  padding: 0 10rpx;
+}
+.med-row {
+  border: 2rpx solid #F1F5F9;
+  border-radius: 12rpx;
+  padding: 16rpx;
+  margin-bottom: 16rpx;
+}
+.med-line {
+  display: flex;
+  align-items: center;
+  margin-bottom: 10rpx;
+}
+.med-line:last-child {
+  margin-bottom: 0;
+}
+.med-name,
+.med-time {
+  flex: 1;
+  border: 2rpx solid #E2E8F0;
+  border-radius: 10rpx;
+  padding: 12rpx 16rpx;
+  font-size: 26rpx;
+  background: #F8FAFC;
+}
+.med-del {
+  color: #EF4444;
+  font-size: 24rpx;
+  padding: 0 10rpx;
+}
+.med-picker {
+  font-size: 24rpx;
+  color: #F97316;
+  background: #FFF3E0;
+  padding: 10rpx 20rpx;
+  border-radius: 999rpx;
+}
+.med-forget {
+  font-size: 22rpx;
+  color: #94A3B8;
+  margin-left: 16rpx;
+}
+.med-duration {
+  font-size: 22rpx;
+  color: #64748B;
+}
+.quick-chip {
+  display: inline-flex;
+  align-items: center;
+  padding: 14rpx 28rpx;
+  border-radius: 999rpx;
+  background: #F1F5F9;
+  font-size: 26rpx;
+  color: #64748B;
+}
+.quick-chip-on {
+  background: #FFF3E0;
+  color: #F97316;
+  font-weight: 600;
+}
+.unit-hint {
+  display: block;
+  font-size: 22rpx;
+  color: #999;
+  margin-top: 10rpx;
+}
+</style>

+ 151 - 398
cfc-frontend/pages/health/health-plan-summary.vue

@@ -51,106 +51,22 @@
         <text class="form-member-save">每个成员独立保存,切换后自动保留</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 class="form-row">
-          <text class="form-label">总胆固醇 (mmol/L)</text>
-          <input class="form-input" type="digit" v-model="lipidCholesterol" placeholder="如 4.5" />
-        </view>
-      </view>
-
-      <!-- 疾病史 -->
-      <view class="form-section">
-        <view class="form-title">疾病史</view>
-        <view v-for="(group, gi) in diseaseGroups" :key="group.key" class="disease-group">
-          <view class="group-head" @click="toggleGroup(group.key)">
-            <text class="group-title">{{ group.title }}</text>
-            <text class="group-arrow">{{ expandedGroups.indexOf(group.key) >= 0 ? '收起' : '展开' }}</text>
-          </view>
-          <view v-if="expandedGroups.indexOf(group.key) >= 0" class="tag-wrap">
-            <view
-              v-for="(item, ii) in group.items"
-              :key="item.name"
-              class="tag-item"
-              :class="{ 'tag-active': selectedNames.indexOf(item.name) >= 0 }"
-              @click="toggleDisease(item.name)">
-              <text>{{ item.name }}</text>
-              <text v-if="item.risk" class="risk-badge" @click.stop="showRiskTip(item.name)">★</text>
-            </view>
-          </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 v-if="selectedDiseases.length > 0" class="selected-area">
-          <view class="selected-title">已选疾病({{ selectedDiseases.length }})</view>
-          <view v-for="(item, si) in selectedDiseases" :key="getSelKey(si)" class="sel-row">
-            <text class="sel-name">{{ item.name }}</text>
-            <picker mode="date" fields="month" :value="item.diagnosedAt || currentMonth" :end="monthEnd" @change="onDiagChange(si, $event)">
-              <view class="sel-diag">{{ item.diagnosedAt ? item.diagnosedAt : '设确诊时间' }}</view>
-            </picker>
-            <text class="sel-del" @click="removeSelected(si)">×</text>
-          </view>
-        </view>
-      </view>
-
-      <!-- 在服药物/治疗 -->
-      <view class="form-section">
-        <view class="form-title">在服药物/治疗</view>
-        <view v-for="(med, idx) in medRows" :key="idx" class="med-row">
-          <view class="med-line">
-            <input class="med-name" type="text" v-model="med.name" placeholder="药物品牌,如 立普妥" />
-            <text class="med-del" @click="removeMed(idx)">删除</text>
-          </view>
-          <view class="med-line">
-            <picker mode="date" fields="month" :value="med.startDate || currentMonth" :end="monthEnd" @change="onMedStartChange(idx, $event)">
-              <view class="med-picker">{{ med.startDate ? med.startDate : '选择开始日期' }}</view>
-            </picker>
-            <text class="med-forget" @click="onForgetClick(idx)">记不清</text>
-          </view>
-          <view class="med-line" v-if="med.durationText">
-            <text class="med-duration">已服时长:{{ med.durationText }}</text>
-          </view>
-          <view class="med-line">
-            <input class="med-time" type="text" v-model="med.time" placeholder="服药时间,如 早饭后一次" />
-          </view>
-        </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>
+      <!-- 分步问卷向导(体征 / 疾病史 / 饮食习惯 / 确认) -->
+      <health-questionnaire-wizard
+        ref="wizard"
+        :member-form-map="memberFormMap"
+        :diet-map="memberDietMap"
+        :active-form-member-id="activeFormMemberId"
+        :submitting="loading"
+        @update-vitals="onWizardVitals"
+        @update-medical="onWizardMedical"
+        @update-diet="onWizardDiet"
+        @submit="onWizardSubmit"
+      />
 
       <!-- 底部按钮 -->
       <view class="btn-pair">
-        <button class="btn-skip" @click="skipToGenerate">跳过</button>
-        <button class="btn-generate" :disabled="loading" @click="saveAndGenerate">{{ loading ? '生成中...' : '保存并生成' }}</button>
+        <button class="btn-skip" @click="skipToGenerate">跳过问卷,直接生成</button>
       </view>
     </view>
 
@@ -267,85 +183,12 @@
 
 <script>
 import { generateHealthPlan, regenerateSection, confirmPlan, saveHealthPlan, getHealthPlanDetail, approvePlan } from '../../utils/api'
-import { getHealthStatus, saveHealthStatus } from '../../utils/api'
+import { getHealthStatus, saveHealthStatus, getDietPreferences, saveDietPreferences, getMyMembership } from '../../utils/api'
 import MemberChipPicker from '@/pages/health/components/MemberChipPicker.vue'
-
-// v2: 预置疾病清单(菌群报告病症风险 ★ + 互联网统计常见病 两源合并去重,8 组 54 项)
-var DISEASE_GROUPS = [
-  { key: 'cardio', title: '心血管代谢', items: [
-    { name: '高血压', risk: false },
-    { name: '糖尿病', risk: false },
-    { name: '血脂异常', risk: false },
-    { name: '冠心病', risk: false },
-    { name: '脑卒中', risk: false },
-    { name: '心力衰竭', risk: false },
-    { name: '心房颤动', risk: false },
-    { name: '慢阻肺', risk: false },
-    { name: '心脑血管疾病', risk: true },
-    { name: 'II型糖尿病', risk: true },
-    { name: '肥胖', risk: true },
-    { name: '超重', risk: false },
-    { name: '高尿酸血症', risk: false },
-    { name: '骨质疏松', risk: false }
-  ]},
-  { key: 'digest', title: '消化系统', items: [
-    { name: '幽门螺杆菌感染', risk: false },
-    { name: '脂肪肝', risk: false },
-    { name: '慢性胃炎', risk: false },
-    { name: '胃食管反流', risk: false },
-    { name: '肠易激综合征', risk: true },
-    { name: '炎症性肠炎', risk: true },
-    { name: '慢性便秘', risk: true },
-    { name: '感染性腹泻', risk: true },
-    { name: '胆结石', risk: false },
-    { name: '炎症性肠病', risk: false },
-    { name: '肝病', risk: true },
-    { name: '胃病', risk: true },
-    { name: '胆病', risk: true },
-    { name: '肾病', risk: true },
-    { name: '腹胀', risk: true }
-  ]},
-  { key: 'immune', title: '过敏免疫', items: [
-    { name: '过敏性鼻炎', risk: true },
-    { name: '哮喘', risk: false },
-    { name: '湿疹', risk: false },
-    { name: '食物过敏', risk: false },
-    { name: '自体免疫病', risk: true }
-  ]},
-  { key: 'neuro', title: '精神神经', items: [
-    { name: '抑郁症', risk: true },
-    { name: '失眠', risk: true },
-    { name: '焦虑障碍', risk: false },
-    { name: '偏头痛', risk: false },
-    { name: '自闭症', risk: true }
-  ]},
-  { key: 'resp', title: '呼吸系统', items: [
-    { name: '肺部感染或疾病', risk: true },
-    { name: '肺部疾病', risk: true }
-  ]},
-  { key: 'tumor', title: '肿瘤', items: [
-    { name: '结直肠癌', risk: true },
-    { name: '肺癌', risk: false },
-    { name: '甲状腺癌', risk: false },
-    { name: '肝癌', risk: false },
-    { name: '胃癌', risk: false },
-    { name: '乳腺癌', risk: false },
-    { name: '食管癌', risk: false }
-  ]},
-  { key: 'child', title: '儿童青少年', items: [
-    { name: '近视', risk: false },
-    { name: '龋齿', risk: false },
-    { name: '注意缺陷多动障碍', risk: false },
-    { name: '甲状腺疾病', risk: true }
-  ]},
-  { key: 'other', title: '其他', items: [
-    { name: '缺铁性贫血', risk: false },
-    { name: '贫血', risk: false }
-  ]}
-]
+import HealthQuestionnaireWizard from '@/pages/health/components/HealthQuestionnaireWizard.vue'
 
 export default {
-  components: { MemberChipPicker },
+  components: { MemberChipPicker, HealthQuestionnaireWizard },
   data() {
     return {
       // 步骤控制
@@ -357,24 +200,10 @@ export default {
       // 目标
       userGoal: '',
       goalLen: 0,
-      // 健康问卷表单
-      form: {
-        heightCm: '',
-        weightKg: '',
-        bloodPressure: '',
-        bloodGlucose: '',
-        notes: ''
-      },
-      lipidCholesterol: '',
-      // 疾病清单很大,不放进首屏 data,避免微信 setData 超限导致成员列表无法渲染
-      expandedGroups: [],
-      selectedDiseases: [],
-      diseaseInput: '',
-      medRows: [{ name: '', startDate: '', durationText: '', time: '' }],
-      currentMonth: '',
-      monthEnd: '',
-      // 多成员分别填写:memberId → 各自的表单快照 { form, lipidCholesterol, selectedDiseases, medRows }
+      // 多成员分别填写:memberId → 各自的表单快照 { form, lipidCholesterol, selectedDiseases, medRows, noMedication }
       memberFormMap: {},
+      // 多成员饮食习惯:memberId → dietPreferences 对象
+      memberDietMap: {},
       activeFormMemberId: '',
       // 方案数据
       planData: {},
@@ -396,10 +225,6 @@ export default {
     }
   },
   computed: {
-    // 仅在问卷步骤暴露疾病清单,减小 step1 的 setData 体积
-    diseaseGroups: function() {
-      return this.step >= 2 ? DISEASE_GROUPS : []
-    },
     canGenerate: function() {
       return this.selectedMemberIds.length > 0 && this.goalLen > 0 && !this.loading
     },
@@ -430,9 +255,6 @@ export default {
       var idx = this.step - 3
       return this.sectionHints[idx] || ''
     },
-    selectedNames: function() {
-      return this.selectedDiseases.map(function(d) { return d.name })
-    },
 selectedMembers: function() {
       var self = this
       var out = []
@@ -476,15 +298,10 @@ selectedMembers: function() {
       this.viewMode = true
       this.loadPlanDetail()
     }
-    // 初始化健康问卷时间
-    var now = new Date()
-    var y = now.getFullYear()
-    var m = now.getMonth() + 1
-    var mStr = m < 10 ? '0' + m : '' + m
-    this.currentMonth = y + '-' + mStr
-    var lastDay = new Date(y, m, 0).getDate()
-    this.monthEnd = y + '-' + mStr + '-' + (lastDay < 10 ? '0' + lastDay : '' + lastDay)
     // 健康问卷数据在成员选择组件 loaded 后按成员异步加载
+    if (!this.viewMode) {
+      this.checkQuestionnaireGate()
+    }
     // 自动执行
     if (options.autoExecute === '1') {
       setTimeout(function() { self.doAutoExecute() }, 1500)
@@ -524,7 +341,7 @@ selectedMembers: function() {
       if (formId) {
         this.activeFormMemberId = String(formId)
         var self = this
-        setTimeout(function() { self.loadHealthForm(formId) }, 100)
+        setTimeout(function() { self.loadHealthForm(formId); self.loadDietForm(formId) }, 100)
       }
     },
     onAddMember: function() {
@@ -540,14 +357,11 @@ selectedMembers: function() {
 
     // ===== 步骤导航 =====
     goToStep2: function() {
-      if (this.expandedGroups.length === 0) {
-        this.expandedGroups = DISEASE_GROUPS.slice(0, 3).map(function(g) { return g.key })
-      }
-      // 多成员调研:确保 activeFormMemberId 指向第一个选中成员,并加载其表单
+      // 多成员调研:确保 activeFormMemberId 指向第一个选中成员,并加载其表单与饮食习惯
       if (!this.activeFormMemberId && this.selectedMemberIds.length > 0) {
         this.activeFormMemberId = String(this.selectedMemberIds[0])
         var self = this
-        setTimeout(function() { self.loadHealthForm(self.activeFormMemberId) }, 100)
+        setTimeout(function() { self.loadHealthForm(self.activeFormMemberId); self.loadDietForm(self.activeFormMemberId) }, 100)
       }
       this.step = 2
     },
@@ -556,19 +370,17 @@ selectedMembers: function() {
       this.generatePlan()
     },
 
-    // ===== 健康问卷方法 (从 health-status-form.vue 移植,支持按成员加载) =====
+    // ===== 健康问卷方法:按成员从服务端加载到 memberFormMap 快照 =====
     loadHealthForm: function(memberId) {
       var self = this
       var mid = memberId || self.activeFormMemberId || ''
-      // 已有该成员的本地快照 → 直接恢复
-      if (mid && self.memberFormMap[mid]) {
-        self.applyFormSnapshot(self.memberFormMap[mid])
-        return
-      }
+      var key = String(mid || '__account')
+      // 本地已有该成员快照 → 无需重复请求
+      if (self.memberFormMap[key]) return
       getHealthStatus(mid || undefined).then(function(res) {
         if (res && res.code === 200 && res.data) {
           var d = res.data
-          var snapshot = {
+          self.$set(self.memberFormMap, key, {
             form: {
               heightCm: d.heightCm || '',
               weightKg: d.weightKg || '',
@@ -577,59 +389,63 @@ selectedMembers: function() {
               notes: d.notes || ''
             },
             lipidCholesterol: self.parseLipidCholesterol(d.bloodLipids),
-            selectedDiseases: [],
-            medRows: self.parseMeds(d.medications)
-          }
-          self.memberFormMap[mid || '__account'] = snapshot
-          self.loadDiseasesInto(snapshot.selectedDiseases, d.diseaseHistory)
-          if (mid) self.applyFormSnapshot(snapshot)
-          else self.applyFormSnapshot({
-            form: snapshot.form,
-            lipidCholesterol: snapshot.lipidCholesterol,
-            selectedDiseases: snapshot.selectedDiseases,
-            medRows: snapshot.medRows
+            selectedDiseases: self.parseDiseases(d.diseaseHistory),
+            medRows: self.parseMeds(d.medications),
+            noMedication: false
           })
         }
       }).catch(function() {})
     },
-    // 切换成员时先保存当前编辑内容,再加载目标成员快照
-    switchMember: function(memberId) {
+    // 按成员加载饮食习惯到 memberDietMap
+    loadDietForm: function(memberId) {
       var self = this
-      if (String(memberId) === String(self.activeFormMemberId)) return
-      // 保存当前成员到快照
-      self.memberFormMap[self.activeFormMemberId || '__account'] = {
-        form: JSON.parse(JSON.stringify(self.form)),
-        lipidCholesterol: self.lipidCholesterol,
-        selectedDiseases: JSON.parse(JSON.stringify(self.selectedDiseases)),
-        medRows: JSON.parse(JSON.stringify(self.medRows))
+      var mid = memberId || self.activeFormMemberId || ''
+      var key = String(mid || '__account')
+      if (self.memberDietMap[key]) return
+      getDietPreferences({ memberId: mid }).then(function(res) {
+        var d = (res && res.code === 200 && res.data) ? res.data : null
+        self.$set(self.memberDietMap, key, self.normalizeDiet(d))
+      }).catch(function() {
+        self.$set(self.memberDietMap, key, self.normalizeDiet(null))
+      })
+    },
+    normalizeDiet: function(d) {
+      return {
+        allergies: (d && d.allergies) || [],
+        absoluteAvoid: (d && d.absoluteAvoid) || [],
+        mustEat: (d && d.mustEat) || [],
+        religiousDiet: (d && d.religiousDiet) || 'none',
+        spiceLevel: (d && d.spiceLevel) || 0,
+        flavorPref: (d && d.flavorPref) || [],
+        cuisinePref: (d && d.cuisinePref) || [],
+        cookingMethods: (d && d.cookingMethods) || [],
+        healthGoals: (d && d.healthGoals) || [],
+        noPreference: !!(d && d.noPreference)
       }
-      self.activeFormMemberId = String(memberId)
-      // 目标成员已有快照 → 恢复;无则按该成员从服务端加载
-      if (self.memberFormMap[self.activeFormMemberId]) {
-        self.applyFormSnapshot(self.memberFormMap[self.activeFormMemberId])
-      } else {
-        self.resetFormFields()
-        self.loadHealthForm(self.activeFormMemberId)
+    },
+    hasDietContent: function(diet) {
+      if (!diet) return false
+      var arrs = [diet.allergies, diet.absoluteAvoid, diet.mustEat, diet.flavorPref, diet.cuisinePref, diet.cookingMethods, diet.healthGoals]
+      for (var i = 0; i < arrs.length; i++) {
+        if (arrs[i] && arrs[i].length > 0) return true
       }
+      if (diet.spiceLevel && Number(diet.spiceLevel) > 0) return true
+      if (diet.religiousDiet && diet.religiousDiet !== 'none') return true
+      return false
     },
-    applyFormSnapshot: function(snapshot) {
-      if (!snapshot) return
-      this.form = JSON.parse(JSON.stringify(snapshot.form || {}))
-      this.lipidCholesterol = snapshot.lipidCholesterol || ''
-      this.selectedDiseases = JSON.parse(JSON.stringify(snapshot.selectedDiseases || []))
-      this.medRows = snapshot.medRows && snapshot.medRows.length > 0 ? JSON.parse(JSON.stringify(snapshot.medRows)) : [{ name: '', startDate: '', durationText: '', time: '' }]
-      var self = this
-      this.selectedDiseases.forEach(function(item) {
-        var gk = self.findGroupKey(item.name)
-        if (gk && self.expandedGroups.indexOf(gk) < 0) self.expandedGroups.push(gk)
-      })
+    // 保存用饮食对象:noPreference 为前端标记,但随表单落库(后端 diet_preferences.no_preference 持久化)
+    dietForSave: function(diet) {
+      var out = {}
+      var keys = ['allergies', 'absoluteAvoid', 'mustEat', 'religiousDiet', 'spiceLevel', 'flavorPref', 'cuisinePref', 'cookingMethods', 'healthGoals', 'noPreference']
+      keys.forEach(function(k) { out[k] = diet[k] })
+      return out
     },
-    resetFormFields: function() {
-      this.form = { heightCm: '', weightKg: '', bloodPressure: '', bloodGlucose: '', notes: '' }
-      this.lipidCholesterol = ''
-      this.selectedDiseases = []
-      this.medRows = [{ name: '', startDate: '', durationText: '', time: '' }]
-      this.diseaseInput = ''
+    // 切换成员:仅切换当前填写对象(数据已按成员写入快照,无需手动保存)
+    switchMember: function(memberId) {
+      if (String(memberId) === String(this.activeFormMemberId)) return
+      this.activeFormMemberId = String(memberId)
+      this.loadHealthForm(this.activeFormMemberId)
+      this.loadDietForm(this.activeFormMemberId)
     },
     parseLipidCholesterol: function(json) {
       try {
@@ -640,26 +456,17 @@ selectedMembers: function() {
       } catch (e) {}
       return ''
     },
-    loadDiseasesInto: function(targetList, json) {
-      var self = this
+    parseDiseases: function(json) {
+      var list = []
       try {
         var arr = JSON.parse(json || '[]')
         if (arr instanceof Array) {
           arr.forEach(function(item) {
-            if (item.name && self.selectedNames.indexOf(item.name) < 0) {
-              targetList.push({
-                name: item.name,
-                diagnosedAt: item.diagnosedAt || '',
-                custom: false
-              })
-              var gk = self.findGroupKey(item.name)
-              if (gk && self.expandedGroups.indexOf(gk) < 0) {
-                self.expandedGroups.push(gk)
-              }
-            }
+            if (item.name) list.push({ name: item.name, diagnosedAt: item.diagnosedAt || '', custom: false })
           })
         }
       } catch (e) {}
+      return list
     },
     parseMeds: function(json) {
       var list = []
@@ -676,103 +483,44 @@ selectedMembers: function() {
           })
         }
       } catch (e) {}
-      if (list.length === 0) list.push({ name: '', startDate: '', durationText: '', time: '' })
       return list
     },
-    toggleGroup: function(key) {
-      var idx = this.expandedGroups.indexOf(key)
-      if (idx >= 0) this.expandedGroups.splice(idx, 1)
-      else this.expandedGroups.push(key)
-    },
-    findGroupKey: function(name) {
-      for (var i = 0; i < DISEASE_GROUPS.length; i++) {
-        var items = DISEASE_GROUPS[i].items
-        for (var j = 0; j < items.length; j++) {
-          if (items[j].name === name) return DISEASE_GROUPS[i].key
-        }
-      }
-      return ''
-    },
-    toggleDisease: function(name) {
-      var idx = -1
-      for (var i = 0; i < this.selectedDiseases.length; i++) {
-        if (this.selectedDiseases[i].name === name) { idx = i; break }
-      }
-      if (idx >= 0) {
-        this.selectedDiseases.splice(idx, 1)
-      } else {
-        this.selectedDiseases.push({ name: name, diagnosedAt: '', custom: false })
-        var gk = this.findGroupKey(name)
-        if (gk && this.expandedGroups.indexOf(gk) < 0) {
-          this.expandedGroups.push(gk)
-        }
-      }
-    },
-    showRiskTip: function(name) {
-      uni.showModal({
-        title: '菌群报告关联',
-        content: '「' + name + '」与肠道菌群报告相关,勾选后出方案时可结合菌群报告风险值评估。',
-        showCancel: false
-      })
-    },
-    addCustomDisease: function() {
-      var name = (this.diseaseInput || '').trim()
-      if (!name) return
-      if (this.selectedNames.indexOf(name) < 0) {
-        this.selectedDiseases.push({ name: name, diagnosedAt: '', custom: true })
-      }
-      this.diseaseInput = ''
-    },
-    getSelKey: function(i) { return 's' + i },
-    removeSelected: function(idx) {
-      this.selectedDiseases.splice(idx, 1)
+    // ===== 向导事件:按当前成员写回快照 =====
+    onWizardVitals: function(flat) {
+      this.mergeWizardForm(flat)
     },
-    onDiagChange: function(idx, e) {
-      this.selectedDiseases[idx].diagnosedAt = e.detail.value
+    onWizardMedical: function(flat) {
+      this.mergeWizardForm(flat)
     },
-    addMed: function() {
-      this.medRows.push({ name: '', startDate: '', durationText: '', time: '' })
+    onWizardDiet: function(diet) {
+      var key = String(this.activeFormMemberId || '__account')
+      this.$set(this.memberDietMap, key, diet)
     },
-    onMedStartChange: function(idx, e) {
-      var start = e.detail.value
-      this.medRows[idx].startDate = start
-      this.medRows[idx].durationText = this.calcDuration(start)
+    onWizardSubmit: function() {
+      this.saveAndGenerate()
     },
-    onForgetClick: function(idx) {
-      var self = this
-      uni.showModal({
-        title: '记不清开始日期',
-        editable: true,
-        placeholderText: '如:约 3 个月',
-        success: function(res) {
-          if (res.confirm && res.content) {
-            self.medRows[idx].durationText = res.content.trim()
-          }
-        }
+    // 合并向导推送的扁平体征/疾病数据到当前成员快照
+    mergeWizardForm: function(flat) {
+      if (!flat) return
+      var key = String(this.activeFormMemberId || '__account')
+      var prev = this.memberFormMap[key] || {}
+      var pf = prev.form || {}
+      this.$set(this.memberFormMap, key, {
+        form: {
+          heightCm: flat.heightCm != null ? flat.heightCm : (pf.heightCm || ''),
+          weightKg: flat.weightKg != null ? flat.weightKg : (pf.weightKg || ''),
+          bloodPressure: flat.bloodPressure != null ? flat.bloodPressure : (pf.bloodPressure || ''),
+          bloodGlucose: flat.bloodGlucose != null ? flat.bloodGlucose : (pf.bloodGlucose || ''),
+          notes: flat.notes != null ? flat.notes : (pf.notes || '')
+        },
+        lipidCholesterol: flat.lipidCholesterol != null ? flat.lipidCholesterol : (prev.lipidCholesterol || ''),
+        selectedDiseases: flat.selectedDiseases != null ? flat.selectedDiseases : (prev.selectedDiseases || []),
+        medRows: flat.medRows != null ? flat.medRows : (prev.medRows || []),
+        noMedication: flat.noMedication != null ? flat.noMedication : (prev.noMedication || false)
       })
     },
-    calcDuration: function(startDate) {
-      if (!startDate) return ''
-      var parts = startDate.split('-')
-      if (parts.length !== 2) return ''
-      var sy = parseInt(parts[0], 10)
-      var sm = parseInt(parts[1], 10)
-      var now = new Date()
-      var months = (now.getFullYear() - sy) * 12 + (now.getMonth() + 1 - sm)
-      if (months < 1) months = 1
-      if (months < 12) return '已服 ' + months + ' 个月'
-      return '已服 ' + Math.floor(months / 12) + ' 年'
-    },
-    removeMed: function(idx) {
-      if (this.medRows.length > 1) this.medRows.splice(idx, 1)
-    },
     buildHealthPayload: function(snapshot) {
-      var s = snapshot || {
-        form: this.form,
-        lipidCholesterol: this.lipidCholesterol,
-        selectedDiseases: this.selectedDiseases,
-        medRows: this.medRows
-      }
+      var s = snapshot || {}
       var lipids = []
       if (s.lipidCholesterol) {
         lipids.push({ name: '总胆固醇', value: parseFloat(s.lipidCholesterol), unit: 'mmol/L', note: '' })
@@ -804,39 +552,21 @@ selectedMembers: function() {
       if (s.form.notes) payload.notes = s.form.notes
       return payload
     },
-    // 当前成员快照(无改动数据时返回 null)
+    // 当前成员快照(无数据时返回 null)
     snapshotForMember: function(memberId) {
-      var key = String(memberId)
-      // 优先用已保存的成员快照,其次当前活动表单
-      if (this.memberFormMap[key]) {
-        var s = this.memberFormMap[key]
-        var p = this.buildHealthPayload(s)
-        return this.hasPayload(p) ? p : null
-      }
-      // 活动成员直接用当前表单
-      if (key === String(this.activeFormMemberId)) {
-        var activePayload = this.buildHealthPayload()
-        return this.hasPayload(activePayload) ? activePayload : null
-      }
-      return null
+      var s = this.memberFormMap[String(memberId)]
+      if (!s) return null
+      var p = this.buildHealthPayload(s)
+      return this.hasPayload(p) ? p : null
     },
     hasPayload: function(payload) {
       if (!payload) return false
       return payload.heightCm || payload.weightKg || payload.bloodPressure || payload.bloodGlucose ||
              payload.bloodLipids || payload.diseaseHistory || payload.medications || payload.notes
     },
-    // 先保存当前活动成员的编辑内容到快照,再逐个成员保存健康档案,最后生成方案
+    // 逐个成员保存健康档案 + 饮食习惯,最后生成方案
     saveAndGenerate: function() {
       var self = this
-      // 保存当前活动成员到快照
-      if (self.activeFormMemberId) {
-        self.memberFormMap[self.activeFormMemberId] = {
-          form: JSON.parse(JSON.stringify(self.form)),
-          lipidCholesterol: self.lipidCholesterol,
-          selectedDiseases: JSON.parse(JSON.stringify(self.selectedDiseases)),
-          medRows: JSON.parse(JSON.stringify(self.medRows))
-        }
-      }
       // 血压格式校验(检查所有成员)
       var members = self.selectedMemberIds.slice()
       for (var i = 0; i < members.length; i++) {
@@ -850,18 +580,25 @@ selectedMembers: function() {
       var saveTasks = []
       members.forEach(function(mid) {
         var payload = self.snapshotForMember(mid)
-        if (!payload) return
-        payload.memberId = mid
-        saveTasks.push(saveHealthStatus(payload))
+        if (payload) {
+          payload.memberId = mid
+          saveTasks.push(saveHealthStatus(payload))
+        }
+        var diet = self.memberDietMap[String(mid)]
+        if (diet && (self.hasDietContent(diet) || diet.noPreference)) {
+          var dietPayload = self.dietForSave(diet)
+          dietPayload.familyMemberId = mid
+          saveTasks.push(saveDietPreferences(dietPayload))
+        }
       })
       if (saveTasks.length === 0) {
-        // 无健康数据,直接生成方案
+        // 无问卷数据,直接生成方案
         self.loading = false
         self.generatePlan()
         return
       }
       Promise.all(saveTasks).then(function() {
-        uni.showToast({ title: '健康信息已保存', icon: 'success' })
+        uni.showToast({ title: '信息已保存', icon: 'success' })
         self.loading = false
         self.generatePlan()
       }).catch(function() {
@@ -918,6 +655,27 @@ selectedMembers: function() {
         }
       })
     },
+    checkQuestionnaireGate: function() {
+      var self = this
+      getMyMembership().then(function(res) {
+        var m = (res && res.data) || {}
+        var level = m.memberLevel || m.levelCode || ''
+        if (level && level !== 'FREE') return
+        uni.showModal({
+          title: '开通 9.9 元/月健康会员',
+          content: '填写健康自检问卷需先开通月度健康会员(9.9 元/月),开通后可填写问卷并生成个性化健康方案。',
+          confirmText: '去开通',
+          cancelText: '暂不',
+          success: function(r) {
+            if (r.confirm) {
+              uni.navigateTo({ url: '/pages/membership/pay?levelCode=LITE&period=monthly' })
+            } else {
+              uni.navigateBack({ delta: 1 })
+            }
+          }
+        })
+      }).catch(function() {})
+    },
     // 付费墙引导:三选一(购买家庭会员 / 购买健康启航计划 / 取消)
     showPaywallSheet: function() {
       uni.showActionSheet({
@@ -1175,13 +933,8 @@ selectedMembers: function() {
         exercise: { content: '', confirmed: false }
       }
       // 重置健康问卷
-      this.form = { heightCm: '', weightKg: '', bloodPressure: '', bloodGlucose: '', notes: '' }
-      this.lipidCholesterol = ''
-      this.selectedDiseases = []
-      this.diseaseInput = ''
-      this.medRows = [{ name: '', startDate: '', durationText: '', time: '' }]
-      this.expandedGroups = DISEASE_GROUPS.slice(0, 3).map(function(g) { return g.key })
       this.memberFormMap = {}
+      this.memberDietMap = {}
       this.activeFormMemberId = ''
       this.pickerMembers = []
       this.goalLen = 0

+ 1 - 4
cfc-frontend/pages/health/my-feedback.vue

@@ -3,7 +3,7 @@
     <view class="mf-list" v-if="list.length > 0">
       <view class="mf-item" v-for="(item, idx) in list" :key="item.id">
         <view class="mf-item-head">
-          <text class="mf-badge" :class="badgeClass(item)">{{ typeText(item) }}</text>
+          <text class="mf-badge" :class="item.feedbackType === 'suggestion' ? 'mf-badge-suggestion' : 'mf-badge-question'">{{ typeText(item) }}</text>
           <text class="mf-time">{{ formatTime(item.createdAt) }}</text>
         </view>
         <text class="mf-content">{{ item.content }}</text>
@@ -75,9 +75,6 @@ export default {
     typeText: function(item) {
       return item.feedbackType === 'suggestion' ? '建议' : '疑问'
     },
-    badgeClass: function(item) {
-      return item.feedbackType === 'suggestion' ? 'mf-badge-suggestion' : 'mf-badge-question'
-    },
     isAnswered: function(item) {
       return item.aiStatus === 'answered' && !!item.aiAnswer
     },

+ 4 - 4
cfc-frontend/pages/home-pages/parent-index.vue

@@ -116,12 +116,12 @@
             </view>
           </view>
           <view class="selfcheck-body">
-            <text class="selfcheck-title">五维家庭自检</text>
+            <text class="selfcheck-title">健康自检问卷</text>
             <text class="selfcheck-subtitle" v-if="selfCheckScore != null">
-              最近得分 {{ selfCheckScore }}/45 分
+              已完成健康自检 · 点击再次填写
             </text>
             <text class="selfcheck-subtitle" v-else>
-              15题 · 5分钟 · 生成五行相生寻源建议
+              4步问卷 · 完成自检解锁能量沙盘
             </text>
           </view>
           <text class="selfcheck-arrow">→</text>
@@ -1151,7 +1151,7 @@ export default {
       uni.navigateTo({ url: '/pages/share/index' })
     },
     goToSelfCheck: function() {
-      uni.navigateTo({ url: '/pages/family/self-check-entry' })
+      uni.navigateTo({ url: '/pages/health/health-plan-summary' })
     },
     loadSelfCheckStatus: function() {
       var self = this

+ 6 - 9
cfc-frontend/pages/index-home/index.vue

@@ -603,12 +603,9 @@ membershipDays: 0,
     },
     selfTestSub: function() {
       if (this.sandboxData) {
-        if (this.selfCheckScore != null) {
-          return '最近自检得分 ' + this.selfCheckScore + '/45 分 · 点击查看'
-        }
-        return '最近综合得分 ' + (this.sandboxData.overallScore || 0) + ' 分 · 点击查看'
+        return '已完成健康自检 · 点击再次填写'
       }
-      return '15 题五维自检 · 约 20 秒'
+      return '4 步健康问卷 · 生成个性化方案 · 解锁能量沙盘'
     },
     // ===== Next Best Action 引导条(C 方案)=====
     // 优先级:沙盘未解锁 > 待填调研 > 待办任务 > 未读通知 > 默认引导
@@ -620,8 +617,8 @@ membershipDays: 0,
         return {
           type: 'self-check',
           icon: '⚡',
-          title: '先完成五维自检',
-          sub: '15 题,约 20 秒,解锁全家能量报告',
+          title: '先完成健康自检',
+          sub: '4 步问卷,解锁全家能量报告',
           btn: '去自检',
           color: 'orange'
         }
@@ -1624,8 +1621,8 @@ title: '添加家庭成员',
           '&periodEnd=' + (firstPending.periodEnd || '')
         uni.navigateTo({ url: url })
       } else {
-        // 无待填调研 → 进入五维自检中间页
-        uni.navigateTo({ url: '/pages/family/self-check-entry' })
+        // 无待填调研 → 进入健康自检问卷(会员门槛由问卷页 onLoad 把关)
+        uni.navigateTo({ url: '/pages/health/health-plan-summary' })
       }
     },
     // ===== Next Best Action 引导条点击 =====

+ 6 - 1
cfc-frontend/pages/teacher/plan-list.vue

@@ -57,9 +57,14 @@ export default {
   },
   onLoad() {
     this.loadPlans()
+    this._onLoadFired = true
   },
   onShow() {
-    this.loadPlans()
+    if (this._onLoadFired) {
+      this._onLoadFired = false
+    } else {
+      this.loadPlans()
+    }
   },
   methods: {
     loadPlans() {

+ 2 - 2
cfc-frontend/pages/teacher/service-tasks.vue

@@ -20,14 +20,14 @@
         @click="viewTaskDetail(task)">
         <view class="task-header">
           <view class="task-title-row">
-            <view class="task-icon" :class="getStatusClass(task)">
+            <view class="task-icon" :class="task.plannerStatus === 'completed' ? 'completed' : (task.assessorStatus === 'completed' ? 'ready' : 'pending')">
               <view v-if="getStatusClass(task) === 'completed'" class="icon-check"></view>
               <view v-else-if="getStatusClass(task) === 'ready'" class="icon-alert"></view>
               <view v-else class="icon-clock"></view>
             </view>
             <text class="task-title">服务执行 #{{ task.id }}</text>
           </view>
-          <text class="task-status" :class="getStatusClass(task)">
+          <text class="task-status" :class="task.plannerStatus === 'completed' ? 'completed' : (task.assessorStatus === 'completed' ? 'ready' : 'pending')">
             {{ getStatusText(task) }}
           </text>
         </view>

+ 1 - 11
cfc-frontend/pages/vendor/team-customers/team-customers.vue

@@ -58,7 +58,7 @@
                 <text>订单</text>
               </view>
 
-              <view class="source-tag" :class="getSourceTagClass(customer.source)">
+              <view class="source-tag" :class="'tag-' + (customer.source || 'default')">
                 <text>{{ getSourceTagLabel(customer.source) }}</text>
               </view>
             </view>
@@ -192,16 +192,6 @@ export default {
       return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0')
     },
 
-    // 获取来源标签样式
-    getSourceTagClass(source) {
-      const classes = {
-        'manual': 'tag-manual',
-        'order': 'tag-order',
-        'referral': 'tag-referral'
-      }
-      return classes[source] || 'tag-default'
-    },
-
     // 获取来源标签文字
     getSourceTagLabel(source) {
       const labels = {

+ 2 - 12
cfc-frontend/pages/wealth/components/DimensionSubDims.vue

@@ -19,11 +19,11 @@
           <view class="dim-item-score-wrap">
             <text
               class="dim-item-score"
-              :class="getScoreClass(d.score)"
+              :class="d.score >= 80 ? 'score-good' : (d.score >= 60 ? 'score-ok' : 'score-low')"
             >{{ d.score }}</text>
             <text
               class="dim-item-level"
-              :class="'level-' + getScoreLevel(d.score)"
+              :class="'level-' + (d.score >= 80 ? 'good' : (d.score >= 60 ? 'ok' : 'low'))"
             >{{ getScoreLevelText(d.score) }}</text>
           </view>
         </view>
@@ -48,21 +48,11 @@ export default {
     dims: { type: Array, default: () => [] }
   },
   methods: {
-    getScoreLevel(score) {
-      if (score >= 80) return 'good'
-      if (score >= 60) return 'ok'
-      return 'low'
-    },
     getScoreLevelText(score) {
       if (score >= 80) return '优秀'
       if (score >= 60) return '良好'
       if (score > 0) return '待提升'
       return '-'
-    },
-    getScoreClass(score) {
-      if (score >= 80) return 'score-good'
-      if (score >= 60) return 'score-ok'
-      return 'score-low'
     }
   }
 }

+ 10 - 1
cfc-frontend/pages/wisdom-detail/cognitive-report.vue

@@ -144,7 +144,7 @@
         <view class="tc-dim-row" v-for="dim in dimensions" :key="dim.key">
           <text class="tc-dim-name">{{ dim.label }}</text>
           <text class="tc-old">{{ prevScore(dim.key) }}</text>
-          <text class="tc-new" :class="scoreUp(dim.key) ? 'sc-up' : (scoreDown(dim.key) ? 'sc-down' : '')">{{ latestScore(dim.key) }}</text>
+          <text class="tc-new" :class="scoreClassMap[dim.key]">{{ latestScore(dim.key) }}</text>
           <text class="tc-change">
             <text v-if="scoreUp(dim.key)" class="change-up">&#x25B2; +{{ scoreDiff(dim.key) }}</text>
             <text v-else-if="scoreDown(dim.key)" class="change-down">&#x25BC; {{ scoreDiff(dim.key) }}</text>
@@ -206,6 +206,15 @@ export default {
         { key: 'trend', label: '综合趋势', display: diff > 0 ? '上升' : (diff < 0 ? '下降' : '持平'), direction: diff > 0 ? 'up' : (diff < 0 ? 'down' : 'flat'), pct: pct },
         { key: 'latest', label: '最新综合', display: latest.overallScore || 0 + '分', direction: 'neutral', pct: 0 }
       ]
+    },
+    scoreClassMap: function() {
+      var map = {}
+      var dims = this.dimensions || []
+      for (var i = 0; i < dims.length; i++) {
+        var diff = this.scoreDiff(dims[i].key)
+        map[dims[i].key] = diff > 0 ? 'sc-up' : (diff < 0 ? 'sc-down' : '')
+      }
+      return map
     }
   },
   onLoad: function(options) {

+ 51 - 0
cfc-langgraph/app/api/adapter.py

@@ -700,6 +700,44 @@ REGENERATE_SECTION_SYSTEM_PROMPT = """你是一个家庭健康方案规划师。
 - 建议要具体可执行"""
 
 
+def _format_health_status(hs: dict) -> str:
+    if not hs:
+        return ""
+    def _arr(v):
+        if not v:
+            return ""
+        if isinstance(v, list):
+            return ", ".join(str(x.get("name", x)) if isinstance(x, dict) else str(x) for x in v)
+        try:
+            parsed = json.loads(v)
+            if isinstance(parsed, list):
+                return ", ".join(str(x.get("name", x)) if isinstance(x, dict) else str(x) for x in parsed)
+        except Exception:
+            pass
+        return v
+    out = []
+    if hs.get("heightCm"):
+        out.append(f"身高{hs['heightCm']}cm")
+    if hs.get("weightKg"):
+        out.append(f"体重{hs['weightKg']}kg")
+    if hs.get("bloodPressure"):
+        out.append(f"血压{hs['bloodPressure']}")
+    if hs.get("bloodGlucose"):
+        out.append(f"空腹血糖{hs['bloodGlucose']}mmol/L")
+    lipids = _arr(hs.get("bloodLipids"))
+    if lipids:
+        out.append(f"血脂:{lipids}")
+    diseases = _arr(hs.get("diseaseHistory"))
+    if diseases:
+        out.append(f"疾病史:{diseases}")
+    meds = _arr(hs.get("medications"))
+    if meds:
+        out.append(f"用药:{meds}")
+    if hs.get("notes"):
+        out.append(f"备注:{hs['notes']}")
+    return "; ".join(out)
+
+
 async def _collect_plan_data(java: JavaClient, retriever: RagRetriever, member_ids_str: str, goal: str, dimensions: str):
     """统一数据收集逻辑"""
     member_ids = [m.strip() for m in member_ids_str.split(",") if m.strip()]
@@ -734,6 +772,13 @@ async def _collect_plan_data(java: JavaClient, retriever: RagRetriever, member_i
             ind["_member_name"] = member["name"]
             all_indicators.append(ind)
 
+    # 2.5 健康自检问卷(health_status;未填写为空 dict)
+    for member in members_info:
+        try:
+            member["health_status"] = await java.get_member_health_status(int(member["id"]))
+        except Exception:
+            member["health_status"] = {}
+
     # 3. 知识库
     kb_results = []
     queries = set()
@@ -883,6 +928,9 @@ async def health_plan_generate(req: HealthPlanRequest):
         body = profile.get("body_metrics", {})
         mind = profile.get("mind_metrics", {})
         parts.append(f"- {m['name']} (年龄: {m['age']})")
+        hs_text = _format_health_status(m.get("health_status"))
+        if hs_text:
+            parts.append(f"  健康自检: {hs_text}")
         if dims:
             parts.append(f"  五维评分: 身{dims.get('body','?')} 智{dims.get('wisdom','?')} 心{dims.get('mind','?')} 行{dims.get('action','?')} 富{dims.get('wealth','?')}")
         if body.get('sleep_dur_avg'):
@@ -940,6 +988,9 @@ async def health_plan_regenerate(req: HealthPlanRegenerateRequest):
     ctx_parts = [f"目标: {req.goal}"]
     for m in members_info:
         ctx_parts.append(f"- {m['name']} (年龄: {m['age']})")
+        hs_text = _format_health_status(m.get("health_status"))
+        if hs_text:
+            ctx_parts.append(f"  健康自检: {hs_text}")
     for ind in all_indicators[:10]:
         if ind.get("status") in ("abnormal", "high", "low", "偏高", "偏低"):
             ctx_parts.append(f"- 【异常】{ind.get('_member_name','')} - {ind.get('indicatorName','')}: {ind.get('indicatorValue','')}")

+ 9 - 0
cfc-langgraph/app/tools/java_client.py

@@ -199,3 +199,12 @@ class JavaClient:
             return data["data"]
         return {}
 
+    async def get_member_health_status(self, member_id: int) -> dict:
+        """获取家庭成员健康自检问卷(health_status,内部端点无用户归属校验)"""
+        client = await self._get_client()
+        resp = await client.post("/api/health-status/internal/get", json={"memberId": member_id})
+        data = resp.json()
+        if data.get("code") == 200 and data.get("data"):
+            return data["data"]
+        return {}
+