Bladeren bron

feat: add gut flora summary fields to health report schema and refactor mind page

Sisyphus 2 maanden geleden
bovenliggende
commit
dad234526e

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

@@ -2552,6 +2552,28 @@ try {
             log.warn("创建health_indicators表可能已存在: {}", e.getMessage());
         }
 
+        // ---------- 健康报告表 新增菌群摘要字段 ----------
+        try {
+            jdbcTemplate.execute("ALTER TABLE health_reports " +
+                    "ADD COLUMN gut_balance_score INT COMMENT '菌群平衡分(0-100)', " +
+                    "ADD COLUMN gut_diversity_score INT COMMENT '菌群多样性分(0-100)', " +
+                    "ADD COLUMN beneficial_bacteria_score INT COMMENT '有益菌分(0-100)', " +
+                    "ADD COLUMN harmful_bacteria_score INT COMMENT '有害菌分(0-100)', " +
+                    "ADD COLUMN core_species_score INT COMMENT '核心菌属分(0-100)', " +
+                    "ADD COLUMN health_rating VARCHAR(100) COMMENT '总分评级,如亚健康/优秀等'");
+            log.info("已为health_reports表添加6个菌群摘要字段");
+        } catch (Exception e) {
+            log.warn("为health_reports表添加字段可能已存在: {}", e.getMessage());
+        }
+
+        // ---------- 健康指标表 新增symptoms字段 ----------
+        try {
+            jdbcTemplate.execute("ALTER TABLE health_indicators ADD COLUMN symptoms TEXT COMMENT '过量/缺乏相关症状描述'");
+            log.info("已为health_indicators表添加symptoms字段");
+        } catch (Exception e) {
+            log.warn("为health_indicators表添加symptoms字段可能已存在: {}", e.getMessage());
+        }
+
         log.info("成长规划师模块、财富维度及健康检测报告数据库表初始化完成");
 
         // 健康打卡表

+ 11 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -57,6 +57,17 @@ public class HealthReportController {
                 ? Integer.valueOf(params.get("nutritionScore").toString()) : null);
         report.setGutAge((String) params.get("gutAge"));
         report.setGutType((String) params.get("gutType"));
+        report.setGutBalanceScore(params.get("gutBalanceScore") != null
+                ? Integer.valueOf(params.get("gutBalanceScore").toString()) : null);
+        report.setGutDiversityScore(params.get("gutDiversityScore") != null
+                ? Integer.valueOf(params.get("gutDiversityScore").toString()) : null);
+        report.setBeneficialBacteriaScore(params.get("beneficialBacteriaScore") != null
+                ? Integer.valueOf(params.get("beneficialBacteriaScore").toString()) : null);
+        report.setHarmfulBacteriaScore(params.get("harmfulBacteriaScore") != null
+                ? Integer.valueOf(params.get("harmfulBacteriaScore").toString()) : null);
+        report.setCoreSpeciesScore(params.get("coreSpeciesScore") != null
+                ? Integer.valueOf(params.get("coreSpeciesScore").toString()) : null);
+        report.setHealthRating((String) params.get("healthRating"));
         report.setFileUrl((String) params.get("fileUrl"));
         report.setRemark((String) params.get("remark"));
 

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthIndicator.java

@@ -40,6 +40,9 @@ public class HealthIndicator implements Serializable {
     /** 评估状态: 正常/偏低/偏高/过多/注意/低风险/未检出等 */
     private String status;
 
+    /** 过量/缺乏相关症状描述文本 */
+    private String symptoms;
+
     /** 排序 */
     private Integer sortOrder;
 

+ 18 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthReport.java

@@ -49,6 +49,24 @@ public class HealthReport implements Serializable {
     /** 肠型 (如 "拟杆菌型") */
     private String gutType;
 
+    /** 菌群平衡分 (0-100) */
+    private Integer gutBalanceScore;
+
+    /** 菌群多样性分 (0-100) */
+    private Integer gutDiversityScore;
+
+    /** 有益菌分 (0-100) */
+    private Integer beneficialBacteriaScore;
+
+    /** 有害菌分 (0-100) */
+    private Integer harmfulBacteriaScore;
+
+    /** 核心菌属分 (0-100) */
+    private Integer coreSpeciesScore;
+
+    /** 总分评级文字,如"亚健康及营养饮食不合理" */
+    private String healthRating;
+
     /** 报告文件URL */
     private String fileUrl;
 

+ 46 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/HealthAnalysisService.java

@@ -124,7 +124,52 @@ public class HealthAnalysisService {
             }
         }
 
-        // 6. 组装菌群概要
+        // 6. 总分评级分析
+        String healthRating = report.getHealthRating();
+        if (healthRating != null && !healthRating.isEmpty()) {
+            if (healthRating.contains("亚健康") || healthRating.contains("不佳") || healthRating.contains("差")) {
+                findings.add("健康评级为" + healthRating + ",建议全面调整饮食与生活习惯");
+                suggestions.add("咨询营养师制定个性化饮食方案");
+                nutritionTags.add("全面营养调理");
+            } else if (healthRating.contains("优秀") || healthRating.contains("良好")) {
+                // 优秀/良好则无需额外建议
+            }
+        }
+
+        // 7. 菌群摘要分数分析
+        Integer gutBalanceScore = report.getGutBalanceScore();
+        Integer gutDiversityScore = report.getGutDiversityScore();
+        Integer beneficialBacteriaScore = report.getBeneficialBacteriaScore();
+        Integer harmfulBacteriaScore = report.getHarmfulBacteriaScore();
+        Integer coreSpeciesScore = report.getCoreSpeciesScore();
+
+        if (gutBalanceScore != null && gutBalanceScore < 40) {
+            findings.add("菌群平衡评分偏低(" + gutBalanceScore + "分),肠道菌群结构失衡");
+            suggestions.add("增加富含膳食纤维的食物,促进菌群平衡");
+            nutritionTags.add("肠道菌群调理");
+        }
+        if (gutDiversityScore != null && gutDiversityScore < 40) {
+            findings.add("菌群多样性评分偏低(" + gutDiversityScore + "分),菌群种类不够丰富");
+            suggestions.add("增加食物多样性,摄入多种发酵食品和益生元");
+            nutritionTags.add("菌群多样性");
+        }
+        if (beneficialBacteriaScore != null && beneficialBacteriaScore < 40) {
+            findings.add("有益菌评分偏低(" + beneficialBacteriaScore + "分),有益菌数量不足");
+            suggestions.add("补充益生菌制剂或富含益生菌的发酵食品");
+            nutritionTags.add("益生菌补充");
+        }
+        if (harmfulBacteriaScore != null && harmfulBacteriaScore > 60) {
+            findings.add("有害菌评分偏高(" + harmfulBacteriaScore + "分),潜在致病菌风险较高");
+            suggestions.add("减少高脂高糖食物,增加抗氧化食物摄入");
+            nutritionTags.add("抗炎饮食");
+        }
+        if (coreSpeciesScore != null && coreSpeciesScore < 40) {
+            findings.add("核心菌属评分偏低(" + coreSpeciesScore + "分),关键菌群匮乏");
+            suggestions.add("增加低聚糖类益生元和膳食纤维摄入");
+            nutritionTags.add("核心菌群养护");
+        }
+
+        // 8. 组装菌群概要
         Map<String, List<HealthGutFlora>> grouped = gutFloraList.stream()
                 .filter(f -> f.getCategory() != null)
                 .collect(Collectors.groupingBy(HealthGutFlora::getCategory));

+ 1 - 1
cfc-backend/src/main/resources/schema.sql

@@ -1390,7 +1390,7 @@ CREATE TABLE IF NOT EXISTS health_reports (
     remark VARCHAR(500) COMMENT '备注',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
-    INDEX idx_child_id (child_id),
+    INDEX idx_user_id (user_id),
     INDEX idx_family_id (family_id),
     INDEX idx_report_type (report_type)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康检测报告表';

+ 0 - 143
cfc-frontend/components/FamilyMemberCard.vue

@@ -1,143 +0,0 @@
-<template>
-  <view class="member-card" :class="{ 'member-card--selected': isSelected }" @click="handleClick">
-    <view class="member-card__avatar" :class="avatarColorClass">
-      <image v-if="member.avatar" class="member-card__avatar-img" :src="member.avatar" mode="aspectFill" />
-      <text v-else class="member-card__avatar-text">{{ member.nickname && member.nickname[0] || '?' }}</text>
-    </view>
-    <text class="member-card__name">{{ member.nickname }}</text>
-    <view class="member-card__tags">
-      <text class="member-card__role">{{ member.roleLabel || (member.memberType === 'child' ? '孩子' : '家长') }}</text>
-      <text v-if="member.danLevel" class="member-card__dan">{{ member.danLevel }}</text>
-    </view>
-    <view v-if="isSelected" class="member-card__check">
-      <text class="member-card__check-icon">✓</text>
-    </view>
-  </view>
-</template>
-
-<script>
-export default {
-  name: 'FamilyMemberCard',
-  props: {
-    member: { type: Object, required: true },
-    isSelected: { type: Boolean, default: false }
-  },
-  computed: {
-    avatarColorClass: function() {
-      var isParent = this.member.memberType === 'parent' || this.member.role === 'parent'
-      if (isParent) return 'avatar-color--parent'
-      return 'avatar-color--child-' + (this.member.id % 6)
-    }
-  },
-  methods: {
-    handleClick() {
-      this.$emit('select', this.member)
-    }
-  }
-}
-</script>
-
-<style scoped>
-.member-card {
-  display: inline-flex;
-  flex-direction: column;
-  align-items: center;
-  width: 120rpx;
-  padding: 16rpx 8rpx 12rpx;
-  border-radius: 16rpx;
-  background: #FFFFFF;
-  border: 2rpx solid transparent;
-  position: relative;
-  transition: all 0.2s ease;
-}
-
-.member-card--selected {
-  border-color: #5B9BD5;
-  background: #EBF5FF;
-}
-
-.member-card__avatar {
-  width: 72rpx;
-  height: 72rpx;
-  border-radius: 50%;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  margin-bottom: 8rpx;
-  overflow: hidden;
-}
-
-.member-card__avatar-img {
-  width: 72rpx;
-  height: 72rpx;
-  border-radius: 50%;
-}
-
-.member-card__avatar-text {
-  font-size: 32rpx;
-  font-weight: 600;
-  color: #FFFFFF;
-}
-
-.member-card__name {
-  font-size: 22rpx;
-  color: #1E293B;
-  font-weight: 500;
-  max-width: 104rpx;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-  text-align: center;
-  line-height: 1.3;
-}
-
-.member-card__tags {
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  margin-top: 4rpx;
-}
-
-.member-card__role {
-  font-size: 18rpx;
-  color: #64748B;
-  line-height: 1.3;
-}
-
-.member-card__dan {
-  font-size: 18rpx;
-  color: #F97316;
-  font-weight: 600;
-  line-height: 1.3;
-}
-
-.member-card__check {
-  position: absolute;
-  bottom: 0;
-  right: 0;
-  width: 32rpx;
-  height: 32rpx;
-  background: #5B9BD5;
-  border-radius: 50% 0 16rpx 0;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-}
-
-.member-card__check-icon {
-  font-size: 18rpx;
-  color: #FFFFFF;
-  font-weight: 700;
-}
-
-/* Avatar colors — 家长统一蓝色,孩子多彩渐变 */
-.avatar-color--parent {
-  background: linear-gradient(135deg, #5B9BD5, #3A7CC4);
-}
-.avatar-color--child-0 { background: linear-gradient(135deg, #5B9BD5, #3A7CC4); }
-.avatar-color--child-1 { background: linear-gradient(135deg, #0EA5E9, #6366F1); }
-.avatar-color--child-2 { background: linear-gradient(135deg, #10B981, #059669); }
-.avatar-color--child-3 { background: linear-gradient(135deg, #EC4899, #E11D48); }
-.avatar-color--child-4 { background: linear-gradient(135deg, #84CC16, #65A30D); }
-.avatar-color--child-5 { background: linear-gradient(135deg, #8B5CF6, #6D28D9); }
-</style>

+ 0 - 176
cfc-frontend/components/FamilyMemberStrip.vue

@@ -1,176 +0,0 @@
-<template>
-  <view class="member-strip" v-if="allMembers.length > 0">
-    <view class="member-strip__header">
-      <text class="member-strip__title">家庭成员</text>
-    </view>
-    <scroll-view
-      v-if="allMembers.length >= 4"
-      class="member-strip__scroll"
-      scroll-x
-      :scroll-with-animation="true"
-      :enhanced="true"
-      :show-scrollbar="false"
-      style="height: 160rpx;"
-    >
-      <view class="member-strip__inner">
-        <!-- "全家" 选项 -->
-        <view
-          class="member-strip__all-card"
-          :class="{ 'member-strip__all-card--selected': selectedMemberId === 0 }"
-          @click="selectAll"
-        >
-          <view class="member-strip__all-icon">
-            <text class="member-strip__all-icon-text">🏠</text>
-          </view>
-          <text class="member-strip__all-label">全家</text>
-        </view>
-        <FamilyMemberCard
-          v-for="member in allMembers"
-          :key="member._key"
-          :member="member"
-          :is-selected="selectedMemberId === member.id && selectedMemberType === member.memberType"
-          @select="onSelect"
-        />
-      </view>
-    </scroll-view>
-    <view v-else class="member-strip__row">
-      <!-- "全家" 选项 -->
-      <view
-        class="member-strip__all-card"
-        :class="{ 'member-strip__all-card--selected': selectedMemberId === 0 }"
-        @click="selectAll"
-      >
-        <view class="member-strip__all-icon">
-          <text class="member-strip__all-icon-text">🏠</text>
-        </view>
-        <text class="member-strip__all-label">全家</text>
-      </view>
-      <FamilyMemberCard
-        v-for="member in allMembers"
-        :key="member._key"
-        :member="member"
-        :is-selected="selectedMemberId === member.id && selectedMemberType === member.memberType"
-        @select="onSelect"
-      />
-    </view>
-  </view>
-</template>
-
-<script>
-import FamilyMemberCard from './FamilyMemberCard.vue'
-
-export default {
-  name: 'FamilyMemberStrip',
-  components: { FamilyMemberCard },
-  props: {
-    members: { type: Array, default: function() { return [] } },
-    selectedMemberId: { type: Number, default: 0 },
-    selectedMemberType: { type: String, default: 'all' }
-  },
-  computed: {
-    allMembers: function() {
-      var list = this.members || []
-      return list.map(function(m) {
-        m._key = m.memberType + '-' + m.id
-        return m
-      })
-    }
-  },
-  methods: {
-    selectAll() {
-      this.$emit('select', { id: 0, memberType: 'all', nickname: '全家' })
-    },
-    onSelect(member) {
-      this.$emit('select', member)
-    }
-  }
-}
-</script>
-
-<style scoped>
-.member-strip {
-  margin: 0 24rpx 16rpx;
-  background: #FFFFFF;
-  border-radius: 20rpx;
-  padding: 16rpx 16rpx 12rpx;
-  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
-}
-
-.member-strip__header {
-  margin-bottom: 12rpx;
-  padding-left: 4rpx;
-}
-
-.member-strip__title {
-  font-size: 24rpx;
-  font-weight: 600;
-  color: #1E293B;
-}
-
-.member-strip__scroll {
-  white-space: nowrap;
-  width: 100%;
-}
-
-.member-strip__inner {
-  display: inline-flex;
-  align-items: flex-start;
-  gap: 12rpx;
-  padding: 0 4rpx;
-}
-
-.member-strip__row {
-  display: flex;
-  align-items: flex-start;
-  gap: 12rpx;
-  padding: 0 4rpx;
-  justify-content: center;
-  flex-wrap: nowrap;
-}
-
-/* "全家" 卡片 */
-.member-strip__all-card {
-  display: inline-flex;
-  flex-direction: column;
-  align-items: center;
-  width: 120rpx;
-  padding: 16rpx 8rpx 12rpx;
-  border-radius: 16rpx;
-  background: #FFFFFF;
-  border: 2rpx solid transparent;
-  flex-shrink: 0;
-}
-
-.member-strip__all-card--selected {
-  border-color: #5B9BD5;
-  background: #EBF5FF;
-}
-
-.member-strip__all-icon {
-  width: 72rpx;
-  height: 72rpx;
-  border-radius: 50%;
-  background: #EBF5FF;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  margin-bottom: 8rpx;
-}
-
-.member-strip__all-icon-text {
-  font-size: 36rpx;
-}
-
-.member-strip__all-label {
-  font-size: 22rpx;
-  color: #1E293B;
-  font-weight: 500;
-  text-align: center;
-  line-height: 1.3;
-}
-
-.member-strip__all-card--selected .member-strip__all-label {
-  color: #5B9BD5;
-  font-weight: 600;
-}
-</style>

+ 31 - 25
cfc-frontend/components/FamilyRelationGraph.vue

@@ -26,7 +26,7 @@ export default {
       type: String,
       required: true,
       validator: function(val) {
-        return ['body', 'mind', 'action'].indexOf(val) !== -1
+        return ['body', 'mind', 'action', 'home'].indexOf(val) !== -1
       }
     },
     selfId: {
@@ -44,19 +44,24 @@ export default {
     intimacyMap: {
       type: Object,
       default: function() { return {} }
+    },
+    interactive: {
+      type: Boolean,
+      default: true
     }
   },
   data: function() {
     return {
-      canvasWidth: 320,
-      canvasHeight: 320,
+      canvasWidth: 240,
+      canvasHeight: 240,
       dpr: 1,
       positionedMembers: [],
       selfPosition: { x: 0, y: 0 },
       themeColors: {
         body: '#8D6E63',
         mind: '#FF6B35',
-        action: '#4CAF50'
+        action: '#4CAF50',
+        home: '#5B9BD5'
       }
     }
   },
@@ -86,7 +91,7 @@ export default {
     initCanvas: function() {
       var sysInfo = uni.getSystemInfoSync()
       this.dpr = sysInfo.pixelRatio || 1
-      var size = uni.upx2px(690)
+      var size = uni.upx2px(480)
       this.canvasWidth = size
       this.canvasHeight = size
       var self = this
@@ -129,8 +134,8 @@ export default {
         return
       }
 
-      var R_INNER = uni.upx2px(160)   // ~74px
-      var R_OUTER = uni.upx2px(480)   // ~222px
+      var R_INNER = uni.upx2px(100)   // ~46px
+      var R_OUTER = uni.upx2px(320)   // ~148px
       var positioned = []
 
       for (var i = 0; i < N; i++) {
@@ -152,8 +157,8 @@ export default {
           memberType: memberType,
           canvasX: x,
           canvasY: y,
-          radius: isSelf ? uni.upx2px(150) : uni.upx2px(120),  // self 72px, others 56px -> divide by 2
-          hitRadius: isSelf ? uni.upx2px(180) : uni.upx2px(140),
+          radius: isSelf ? uni.upx2px(80) : uni.upx2px(60),  // smaller nodes
+          hitRadius: isSelf ? uni.upx2px(100) : uni.upx2px(80),
           isSelf: isSelf,
           intimacy: this.getIntimacy(member.id, 'closeness'),
           communication: this.getIntimacy(member.id, 'communication'),
@@ -310,7 +315,7 @@ export default {
 
     // 绘制环形能量进度条
     drawEnergyRing: function(ctx, x, y, radius, fillRatio, color) {
-      var lineWidth = 4
+      var lineWidth = 2
       // 灰色底圈
       ctx.beginPath()
       ctx.arc(x, y, radius, 0, 2 * Math.PI)
@@ -346,9 +351,9 @@ export default {
       if (member.isSelf) {
         // self:外层发光描边
         ctx.beginPath()
-        ctx.arc(x, y, r + 6, 0, 2 * Math.PI)
+        ctx.arc(x, y, r + 3, 0, 2 * Math.PI)
         ctx.strokeStyle = themeColor
-        ctx.lineWidth = 3
+        ctx.lineWidth = 2
         ctx.globalAlpha = 0.4
         ctx.stroke()
         ctx.globalAlpha = 1.0
@@ -359,12 +364,12 @@ export default {
         ctx.fillStyle = '#FFFFFF'
         ctx.fill()
         ctx.strokeStyle = themeColor
-        ctx.lineWidth = 3
+        ctx.lineWidth = 2
         ctx.stroke()
 
         // 中心文字(昵称首字)
         ctx.fillStyle = themeColor
-        ctx.font = 'bold ' + Math.round(r * 1.0) + 'px sans-serif'
+        ctx.font = 'bold ' + Math.round(r * 0.9) + 'px sans-serif'
         ctx.textAlign = 'center'
         ctx.textBaseline = 'middle'
         var displayName = (member.nickname && member.nickname.charAt(0)) || '我'
@@ -381,11 +386,11 @@ export default {
         ctx.stroke()
 
         // 环形能量进度条(在形状外围)
-        this.drawEnergyRing(ctx, x, y, r + 5, fillRatio, themeColor)
+        this.drawEnergyRing(ctx, x, y, r + 3, fillRatio, themeColor)
 
         // 中心昵称首字
         ctx.fillStyle = '#4A5568'
-        ctx.font = 'bold ' + Math.round(r * 0.7) + 'px sans-serif'
+        ctx.font = 'bold ' + Math.round(r * 0.65) + 'px sans-serif'
         ctx.textAlign = 'center'
         ctx.textBaseline = 'middle'
         var displayName = (member.nickname && member.nickname.charAt(0)) || '?'
@@ -406,9 +411,9 @@ export default {
 
       if (member.isSelf) {
         ctx.beginPath()
-        ctx.arc(x, y, r + 6, 0, 2 * Math.PI)
+        ctx.arc(x, y, r + 3, 0, 2 * Math.PI)
         ctx.setStrokeStyle(themeColor)
-        ctx.setLineWidth(3)
+        ctx.setLineWidth(2)
         ctx.setGlobalAlpha(0.4)
         ctx.stroke()
         ctx.setGlobalAlpha(1.0)
@@ -418,11 +423,11 @@ export default {
         ctx.setFillStyle('#FFFFFF')
         ctx.fill()
         ctx.setStrokeStyle(themeColor)
-        ctx.setLineWidth(3)
+        ctx.setLineWidth(2)
         ctx.stroke()
 
         ctx.setFillStyle(themeColor)
-        ctx.font = 'bold ' + Math.round(r * 1.0) + 'px sans-serif'
+        ctx.font = 'bold ' + Math.round(r * 0.9) + 'px sans-serif'
         ctx.setTextAlign('center')
         ctx.setTextBaseline('middle')
         var name = (member.nickname && member.nickname.charAt(0)) || '我'
@@ -437,25 +442,25 @@ export default {
 
         // 环形进度条
         ctx.beginPath()
-        ctx.arc(x, y, r + 5, 0, 2 * Math.PI)
+        ctx.arc(x, y, r + 3, 0, 2 * Math.PI)
         ctx.setStrokeStyle('rgba(200, 200, 200, 0.35)')
-        ctx.setLineWidth(4)
+        ctx.setLineWidth(2)
         ctx.stroke()
 
         if (fillRatio > 0) {
           ctx.beginPath()
           var startAngle = -Math.PI / 2
           var endAngle = startAngle + fillRatio * 2 * Math.PI
-          ctx.arc(x, y, r + 5, startAngle, endAngle)
+          ctx.arc(x, y, r + 3, startAngle, endAngle)
           ctx.setStrokeStyle(themeColor)
-          ctx.setLineWidth(4)
+          ctx.setLineWidth(2)
           ctx.setLineCap('round')
           ctx.stroke()
           ctx.setLineCap('butt')
         }
 
         ctx.setFillStyle('#4A5568')
-        ctx.font = 'bold ' + Math.round(r * 0.7) + 'px sans-serif'
+        ctx.font = 'bold ' + Math.round(r * 0.65) + 'px sans-serif'
         ctx.setTextAlign('center')
         ctx.setTextBaseline('middle')
         var name = (member.nickname && member.nickname.charAt(0)) || '?'
@@ -465,6 +470,7 @@ export default {
 
     // Canvas 点击命中检测
     onCanvasTap: function(e) {
+      if (!this.interactive) return
       if (!e || !e.detail || typeof e.detail.x === 'undefined') return
 
       var self = this

+ 7 - 0
cfc-frontend/pages.json

@@ -153,6 +153,13 @@
         "navigationStyle": "custom"
       }
     },
+    {
+      "path": "pages/index/member-home-detail",
+      "style": {
+        "navigationBarTitleText": "成员详情",
+        "navigationStyle": "custom"
+      }
+    },
     {
       "path": "pages/profile/profile",
       "style": {

+ 1 - 22
cfc-frontend/pages/action/index.vue

@@ -5,14 +5,6 @@
       tagline="知行合一 · 快乐成长"
       quote="千里之行,始于足下" />
 
-    <!-- 家庭成员切换条 -->
-    <FamilyMemberStrip
-      v-if="isLoggedIn && familyMembersVisible.length > 0"
-      :members="familyMembersVisible"
-      :selectedMemberId="selectedMemberId"
-      :selectedMemberType="selectedMemberType"
-      @select="onMemberSelect" />
-
     <!-- 游客登录引导卡 -->
     <template v-if="!isLoggedIn">
       <LoginGuideCard
@@ -131,12 +123,11 @@ import DimensionActivities from '../../components/DimensionActivities.vue'
 import DimensionProducts from '../../components/DimensionProducts.vue'
 import ContactCard from '../../components/ContactCard.vue'
 import ContactImport from '../../components/ContactImport.vue'
-import FamilyMemberStrip from '../../components/FamilyMemberStrip.vue'
 import FamilyRelationGraph from '../../components/FamilyRelationGraph.vue'
 import { getVisibleSections, getEnergyOverview, getFamilyEnergySandbox, getTodayTasksByCategory, getActivityList, getProductsByDomain, getChildren, getContactList, getVisibleFamilyMembers } from '../../utils/api.js'
 
 export default {
-  components: { PageBanner, LoginGuideCard, FamilyEnergyBar, UserQuickEntry, DimensionTasks, DimensionActivities, DimensionProducts, ContactCard, ContactImport, FamilyMemberStrip, FamilyRelationGraph },
+  components: { PageBanner, LoginGuideCard, FamilyEnergyBar, UserQuickEntry, DimensionTasks, DimensionActivities, DimensionProducts, ContactCard, ContactImport, FamilyRelationGraph },
   data() {
     return {
       isLoggedIn: false,
@@ -146,8 +137,6 @@ export default {
       userName: '',
       children: [],
       familyMembersVisible: [],
-      selectedMemberId: 0,
-      selectedMemberType: 'all',
       sandboxData: null,
       dualDimension: null,
       visibleSections: [],
@@ -348,16 +337,6 @@ export default {
         self.familyMembersVisible = []
       })
     },
-    onMemberSelect: function(member) {
-      if (member.memberType === 'all') {
-        this.selectedMemberId = 0
-        this.selectedMemberType = 'all'
-      } else {
-        this.selectedMemberId = member.id
-        this.selectedMemberType = member.memberType
-      }
-      // TODO: 根据选中成员重新加载对应维度数据
-    },
     scrollToSection: function(section) {
       uni.pageScrollTo({ selector: '#' + section, duration: 300 })
     },

+ 42 - 1
cfc-frontend/pages/action/member-action-detail.vue

@@ -29,6 +29,15 @@
       </view>
     </view>
 
+    <!-- 家庭成员关系图谱(只读) -->
+    <FamilyRelationGraph
+      dimensionCode="action"
+      :selfId="selfId"
+      :members="graphMembers"
+      :energyMap="energyMapForGraph"
+      :intimacyMap="intimacyMapForGraph"
+      :interactive="false" />
+
     <!-- 今日任务 -->
     <DimensionTasks
       dimensionCode="action"
@@ -57,16 +66,18 @@
 
 <script>
 import FamilyEnergyBar from '../../components/FamilyEnergyBar.vue'
+import FamilyRelationGraph from '../../components/FamilyRelationGraph.vue'
 import DimensionTasks from '../../components/DimensionTasks.vue'
 import DimensionActivities from '../../components/DimensionActivities.vue'
 import DimensionProducts from '../../components/DimensionProducts.vue'
 import { getEnergyOverview, getFamilyEnergySandbox, getTodayTasksByCategory, getActivityList, getProductsByDomain, getChildren } from '../../utils/api.js'
 
 export default {
-  components: { FamilyEnergyBar, DimensionTasks, DimensionActivities, DimensionProducts },
+  components: { FamilyEnergyBar, FamilyRelationGraph, DimensionTasks, DimensionActivities, DimensionProducts },
   data() {
     return {
       childId: null,
+      selfId: null,
       memberInfo: null,
       sandboxData: null,
       dualDimension: null,
@@ -75,10 +86,40 @@ export default {
       dimensionProducts: []
     }
   },
+  computed: {
+    graphMembers: function() {
+      if (!this.sandboxData || !this.sandboxData.members) return []
+      return this.sandboxData.members.map(function(m) {
+        return {
+          id: m.memberId || m.id,
+          nickname: m.name || m.nickname || '成员',
+          memberType: m.memberType || 'child'
+        }
+      })
+    },
+    energyMapForGraph: function() {
+      var map = {}
+      if (this.sandboxData && this.sandboxData.members) {
+        for (var i = 0; i < this.sandboxData.members.length; i++) {
+          var m = this.sandboxData.members[i]
+          map[m.memberId || m.id] = {
+            bodyScore: m.bodyScore || 0,
+            mindScore: m.mindScore || 0,
+            actionScore: m.actionScore || 0
+          }
+        }
+      }
+      return map
+    },
+    intimacyMapForGraph: function() {
+      return {}
+    }
+  },
   onLoad: function(options) {
     this.childId = options.childId || uni.getStorageSync('currentChildId') || null
   },
   onShow: function() {
+    this.selfId = uni.getStorageSync('userId') || null
     this.loadChildren()
     this.loadSandboxData()
     if (this.childId) {

+ 1 - 22
cfc-frontend/pages/body/index.vue

@@ -5,14 +5,6 @@
       tagline="科学管理全家健康"
       quote="身体健康是幸福的基础" />
 
-    <!-- 家庭成员切换条 -->
-    <FamilyMemberStrip
-      v-if="isLoggedIn && familyMembersVisible.length > 0"
-      :members="familyMembersVisible"
-      :selectedMemberId="selectedMemberId"
-      :selectedMemberType="selectedMemberType"
-      @select="onMemberSelect" />
-
     <!-- 游客专属区块 -->
     <template v-if="!isLoggedIn">
       <LoginGuideCard
@@ -231,7 +223,6 @@ import UserQuickEntry from '../../components/UserQuickEntry.vue'
 import DimensionTasks from '../../components/DimensionTasks.vue'
 import DimensionActivities from '../../components/DimensionActivities.vue'
 import DimensionProducts from '../../components/DimensionProducts.vue'
-import FamilyMemberStrip from '../../components/FamilyMemberStrip.vue'
 import FamilyRelationGraph from '../../components/FamilyRelationGraph.vue'
 import { getVisibleSections, getEnergyOverview, getChildren, getTodayTasksByCategory, getActivityList, getProductsByDomain, getFamilyEnergySandbox, getVisibleFamilyMembers } from '../../utils/api.js'
 import config from '../../config.js'
@@ -264,7 +255,7 @@ const healthRequest = function(url, data) {
 }
 
 export default {
-  components: { PageBanner, LoginGuideCard, HealthTips, FamilyEnergyBar, UserQuickEntry, DimensionTasks, DimensionActivities, DimensionProducts, FamilyMemberStrip, FamilyRelationGraph },
+  components: { PageBanner, LoginGuideCard, HealthTips, FamilyEnergyBar, UserQuickEntry, DimensionTasks, DimensionActivities, DimensionProducts, FamilyRelationGraph },
   data() {
     return {
       isLoggedIn: false,
@@ -274,8 +265,6 @@ export default {
       userName: '',
       children: [],
       familyMembersVisible: [],
-      selectedMemberId: 0,
-      selectedMemberType: 'all',
       sandboxData: null,
       dualDimension: null,
       visibleSections: [],
@@ -473,16 +462,6 @@ export default {
         self.familyMembersVisible = []
       })
     },
-    onMemberSelect: function(member) {
-      if (member.memberType === 'all') {
-        this.selectedMemberId = 0
-        this.selectedMemberType = 'all'
-      } else {
-        this.selectedMemberId = member.id
-        this.selectedMemberType = member.memberType
-      }
-      // TODO: 根据选中成员重新加载对应维度数据
-    },
     scrollToSection: function(section) {
       uni.pageScrollTo({ selector: '#' + section, duration: 300 })
     },

+ 42 - 1
cfc-frontend/pages/body/member-body-detail.vue

@@ -29,6 +29,15 @@
       </view>
     </view>
 
+    <!-- 家庭成员关系图谱(只读) -->
+    <FamilyRelationGraph
+      dimensionCode="body"
+      :selfId="selfId"
+      :members="graphMembers"
+      :energyMap="energyMapForGraph"
+      :intimacyMap="intimacyMapForGraph"
+      :interactive="false" />
+
     <!-- 今日任务 -->
     <DimensionTasks
       dimensionCode="body"
@@ -57,16 +66,18 @@
 
 <script>
 import FamilyEnergyBar from '../../components/FamilyEnergyBar.vue'
+import FamilyRelationGraph from '../../components/FamilyRelationGraph.vue'
 import DimensionTasks from '../../components/DimensionTasks.vue'
 import DimensionActivities from '../../components/DimensionActivities.vue'
 import DimensionProducts from '../../components/DimensionProducts.vue'
 import { getEnergyOverview, getFamilyEnergySandbox, getTodayTasksByCategory, getActivityList, getProductsByDomain, getChildren } from '../../utils/api.js'
 
 export default {
-  components: { FamilyEnergyBar, DimensionTasks, DimensionActivities, DimensionProducts },
+  components: { FamilyEnergyBar, FamilyRelationGraph, DimensionTasks, DimensionActivities, DimensionProducts },
   data() {
     return {
       childId: null,
+      selfId: null,
       memberInfo: null,
       sandboxData: null,
       dualDimension: null,
@@ -75,10 +86,40 @@ export default {
       dimensionProducts: []
     }
   },
+  computed: {
+    graphMembers: function() {
+      if (!this.sandboxData || !this.sandboxData.members) return []
+      return this.sandboxData.members.map(function(m) {
+        return {
+          id: m.memberId || m.id,
+          nickname: m.name || m.nickname || '成员',
+          memberType: m.memberType || 'child'
+        }
+      })
+    },
+    energyMapForGraph: function() {
+      var map = {}
+      if (this.sandboxData && this.sandboxData.members) {
+        for (var i = 0; i < this.sandboxData.members.length; i++) {
+          var m = this.sandboxData.members[i]
+          map[m.memberId || m.id] = {
+            bodyScore: m.bodyScore || 0,
+            mindScore: m.mindScore || 0,
+            actionScore: m.actionScore || 0
+          }
+        }
+      }
+      return map
+    },
+    intimacyMapForGraph: function() {
+      return {}
+    }
+  },
   onLoad: function(options) {
     this.childId = options.childId || uni.getStorageSync('currentChildId') || null
   },
   onShow: function() {
+    this.selfId = uni.getStorageSync('userId') || null
     this.loadChildren()
     this.loadSandboxData()
     if (this.childId) {

+ 354 - 0
cfc-frontend/pages/index/member-home-detail.vue

@@ -0,0 +1,354 @@
+<template>
+  <view class="detail-container">
+    <!-- 顶部导航 -->
+    <view class="nav-bar">
+      <view class="nav-back" @click="goBack">
+        <text class="nav-back-icon">&#x2190;</text>
+      </view>
+      <text class="nav-title">成员详情</text>
+    </view>
+
+    <!-- 加载中 -->
+    <view class="loading" v-if="loading">加载中...</view>
+
+    <template v-if="!loading">
+      <!-- 成员信息卡 -->
+      <view class="member-card" v-if="memberInfo">
+        <view class="member-avatar">
+          <text class="avatar-text">{{ memberInfo.name && memberInfo.name.charAt(0) || '?' }}</text>
+        </view>
+        <view class="member-info">
+          <text class="member-name">{{ memberInfo.name || '家庭成员' }}</text>
+          <text class="member-role">{{ memberInfo.familyRole || (memberInfo.memberType === 'parent' ? '家长' : '孩子') }}</text>
+        </view>
+        <view class="member-score" v-if="memberInfo.overallScore != null">
+          <text class="score-value">{{ memberInfo.overallScore }}</text>
+          <text class="score-label">综合能量</text>
+        </view>
+      </view>
+
+      <!-- 家庭成员关系图谱(只读) -->
+      <FamilyRelationGraph
+        dimensionCode="home"
+        :selfId="selfId"
+        :members="graphMembers"
+        :energyMap="energyMapForGraph"
+        :intimacyMap="intimacyMapForGraph"
+        :interactive="false" />
+
+      <!-- 五维能量概况 -->
+      <view class="energy-section" v-if="memberInfo">
+        <view class="section-title">五维能量</view>
+        <view class="energy-grid">
+          <view class="energy-item" v-for="dim in energyList" :key="dim.code">
+            <text class="energy-icon">{{ dim.icon }}</text>
+            <view class="energy-bar-track">
+              <view class="energy-bar-fill" :style="{ width: dim.score + '%', background: dim.color }"></view>
+            </view>
+            <text class="energy-score">{{ dim.score }}</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 今日任务 -->
+      <DimensionTasks
+        v-if="childId"
+        dimensionCode="all"
+        :tasks="dimensionTasks"
+        @taskClick="goTasks"
+        @moreTasks="goTasks" />
+    </template>
+  </view>
+</template>
+
+<script>
+import FamilyRelationGraph from '../../components/FamilyRelationGraph.vue'
+import DimensionTasks from '../../components/DimensionTasks.vue'
+import { getFamilyEnergySandbox, getChildren, getTodayTasksByCategory } from '../../utils/api.js'
+
+export default {
+  components: { FamilyRelationGraph, DimensionTasks },
+  data() {
+    return {
+      memberId: null,
+      memberType: 'child',
+      selfId: null,
+      memberInfo: null,
+      sandboxData: null,
+      dimensionTasks: [],
+      loading: true
+    }
+  },
+  computed: {
+    graphMembers: function() {
+      if (!this.sandboxData || !this.sandboxData.members) return []
+      return this.sandboxData.members.map(function(m) {
+        return {
+          id: m.memberId || m.id,
+          nickname: m.name || m.nickname || '成员',
+          memberType: m.memberType || 'child'
+        }
+      })
+    },
+    energyMapForGraph: function() {
+      var map = {}
+      if (this.sandboxData && this.sandboxData.members) {
+        for (var i = 0; i < this.sandboxData.members.length; i++) {
+          var m = this.sandboxData.members[i]
+          map[m.memberId || m.id] = {
+            bodyScore: m.bodyScore || 0,
+            mindScore: m.mindScore || 0,
+            actionScore: m.actionScore || 0
+          }
+        }
+      }
+      return map
+    },
+    intimacyMapForGraph: function() {
+      return {}
+    },
+    childId: function() {
+      // 仅当成员是孩子时才返回 childId
+      return this.memberType === 'child' ? this.memberId : null
+    },
+    energyList: function() {
+      var dims = [
+        { code: 'body', icon: '🌏', label: '身', color: '#8D6E63' },
+        { code: 'mind', icon: '🔥', label: '心', color: '#FF6B35' },
+        { code: 'wisdom', icon: '⚔️', label: '智', color: '#F59E0B' },
+        { code: 'action', icon: '🌿', label: '行', color: '#4CAF50' },
+        { code: 'wealth', icon: '💧', label: '富', color: '#3B82F6' }
+      ]
+      var self = this
+      return dims.map(function(d) {
+        var score = 0
+        if (self.memberInfo) {
+          var key = d.code + 'Score'
+          score = self.memberInfo[key] || 0
+        }
+        return {
+          code: d.code,
+          icon: d.icon,
+          label: d.label,
+          score: score,
+          color: d.color
+        }
+      })
+    }
+  },
+  onLoad: function(options) {
+    this.memberId = options.memberId || null
+    this.memberType = options.memberType || 'child'
+  },
+  onShow: function() {
+    if (!this.memberId) {
+      uni.showToast({ title: '缺少成员信息', icon: 'none' })
+      return
+    }
+    this.selfId = uni.getStorageSync('userId') || null
+    this.loadData()
+  },
+  methods: {
+    loadData: function() {
+      var self = this
+      self.loading = true
+
+      // 1. 加载能量沙盘(获取所有成员数据)
+      getFamilyEnergySandbox().then(function(res) {
+        if (res && res.data) {
+          self.sandboxData = res.data
+          // 从沙盘中查找当前成员
+          var members = res.data.members || []
+          for (var i = 0; i < members.length; i++) {
+            var m = members[i]
+            if ((m.memberId || m.id) == self.memberId) {
+              self.memberInfo = m
+              break
+            }
+          }
+        }
+      }).catch(function(e) {
+        console.log('获取沙盘数据失败', e)
+      })
+
+      // 2. 尝试从孩子列表获取详细信息(如果是孩子)
+      if (self.memberType === 'child') {
+        getChildren().then(function(res) {
+          if (res.code === 200 && res.data) {
+            for (var i = 0; i < res.data.length; i++) {
+              var c = res.data[i]
+              if (c.childId == self.memberId || c.id == self.memberId) {
+                if (!self.memberInfo) {
+                  self.memberInfo = {}
+                }
+                // 补充孩子专属字段
+                self.memberInfo.name = c.name || c.nickname || self.memberInfo.name
+                self.memberInfo.nickname = c.nickname || c.name
+                self.memberInfo.memberType = 'child'
+                break
+              }
+            }
+          }
+        }).catch(function(e) {
+          console.log('获取孩子列表失败', e)
+        })
+      }
+
+      // 3. 加载今日任务
+      if (self.memberType === 'child' && self.memberId) {
+        getTodayTasksByCategory(self.memberId, 'all').then(function(res) {
+          if (res && res.data) {
+            self.dimensionTasks = Array.isArray(res.data) ? res.data.slice(0, 5) : []
+          }
+        }).catch(function() { self.dimensionTasks = [] })
+      }
+
+      self.loading = false
+    },
+    goTasks: function() {
+      uni.switchTab({ url: '/pages/tasks/tasks' })
+    },
+    goBack: function() {
+      uni.navigateBack()
+    }
+  }
+}
+</script>
+
+<style scoped>
+.detail-container {
+  min-height: 100vh;
+  background: #f5f7fa;
+  padding-bottom: 40rpx;
+}
+.nav-bar {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  padding: 20rpx 30rpx;
+  background: #fff;
+}
+.nav-back {
+  padding: 10rpx;
+}
+.nav-back-icon {
+  font-size: 36rpx;
+  color: #333;
+}
+.nav-title {
+  font-size: 32rpx;
+  font-weight: bold;
+  color: #333;
+  margin-left: 16rpx;
+}
+.loading {
+  text-align: center;
+  padding: 60rpx;
+  color: #999;
+  font-size: 28rpx;
+}
+.member-card {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin: 20rpx 30rpx;
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 24rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
+}
+.member-avatar {
+  width: 80rpx;
+  height: 80rpx;
+  border-radius: 40rpx;
+  background: linear-gradient(135deg, #5B9BD5, #3A7CC4);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 20rpx;
+}
+.avatar-text {
+  font-size: 32rpx;
+  color: #fff;
+  font-weight: bold;
+}
+.member-info {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+}
+.member-name {
+  font-size: 30rpx;
+  font-weight: bold;
+  color: #333;
+}
+.member-role {
+  font-size: 24rpx;
+  color: #999;
+  margin-top: 4rpx;
+}
+.member-score {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+.score-value {
+  font-size: 40rpx;
+  font-weight: bold;
+  color: #5B9BD5;
+}
+.score-label {
+  font-size: 22rpx;
+  color: #999;
+}
+
+/* 五维能量 */
+.energy-section {
+  margin: 20rpx 30rpx;
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 24rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
+}
+.section-title {
+  font-size: 30rpx;
+  font-weight: bold;
+  color: #333;
+  margin-bottom: 20rpx;
+}
+.energy-grid {
+  display: flex;
+  flex-direction: column;
+  gap: 16rpx;
+}
+.energy-item {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  gap: 16rpx;
+}
+.energy-icon {
+  font-size: 32rpx;
+  width: 40rpx;
+  text-align: center;
+}
+.energy-bar-track {
+  flex: 1;
+  height: 16rpx;
+  background: #f0f0f0;
+  border-radius: 8rpx;
+  overflow: hidden;
+}
+.energy-bar-fill {
+  height: 100%;
+  border-radius: 8rpx;
+  transition: width 0.3s ease;
+}
+.energy-score {
+  font-size: 26rpx;
+  font-weight: bold;
+  color: #333;
+  width: 48rpx;
+  text-align: right;
+}
+</style>

+ 34 - 19
cfc-frontend/pages/index/parent-index.vue

@@ -9,13 +9,15 @@
   tagline="家庭健康成长俱乐部"
   quote="给全家的一站式幸福提案" />
 
-<!-- ===== 家庭成员切换条 ===== -->
-<FamilyMemberStrip
+<!-- ===== 家庭成员关系图谱 ===== -->
+<FamilyRelationGraph
   v-if="familyMembersVisible.length > 0"
+  dimensionCode="home"
+  :selfId="selfMemberId"
   :members="familyMembersVisible"
-  :selectedMemberId="selectedMemberId"
-  :selectedMemberType="selectedMemberType"
-  @select="onMemberSelect" />
+  :energyMap="energyMapForGraph"
+  :intimacyMap="intimacyMapForGraph"
+  @memberTap="goMemberDetail" />
 
       <!-- 能量沙盘 -->
       <WuxingSandbox
@@ -304,11 +306,11 @@ import PlayfulButton from '../../components/PlayfulButton.vue'
 import BaseBadge from '../../components/BaseBadge.vue'
 import BaseEmpty from '../../components/BaseEmpty.vue'
 import BaseLoading from '../../components/BaseLoading.vue'
-import FamilyMemberStrip from '../../components/FamilyMemberStrip.vue'
+import FamilyRelationGraph from '../../components/FamilyRelationGraph.vue'
 import WuxingSandbox from '../../components/wuxing-sandbox.vue'
 
 export default {
-  components: { PageBanner, DimensionActivities, DimensionProducts, PlayfulCard, PlayfulButton, BaseBadge, BaseEmpty, BaseLoading, FamilyMemberStrip, WuxingSandbox },
+  components: { PageBanner, DimensionActivities, DimensionProducts, PlayfulCard, PlayfulButton, BaseBadge, BaseEmpty, BaseLoading, FamilyRelationGraph, WuxingSandbox },
   data() {
     return {
       nickname: '',
@@ -331,8 +333,7 @@ export default {
       _initialLoadDone: false,
       _familyId: null,
       familyMembersVisible: [],
-      selectedMemberId: 0,
-      selectedMemberType: 'all',
+      selfMemberId: null,
       energyData: null,
       energyDimensions: [],
       totalEnergy: 0,
@@ -383,6 +384,23 @@ export default {
       const c = this.selectedChild
       if (!c) return false
       return (c.streakDays >= 7) || (c.streakDays >= 30) || (c.totalPoints >= 1000)
+    },
+    energyMapForGraph() {
+      var map = {}
+      if (this.sandboxData && this.sandboxData.members) {
+        for (var i = 0; i < this.sandboxData.members.length; i++) {
+          var m = this.sandboxData.members[i]
+          map[m.memberId || m.id] = {
+            bodyScore: m.bodyScore || 0,
+            mindScore: m.mindScore || 0,
+            actionScore: m.actionScore || 0
+          }
+        }
+      }
+      return map
+    },
+    intimacyMapForGraph() {
+      return {}
     }
   },
   // 使用 mounted 而非 onLoad,因为本组件作为条件渲染子组件(v-if),
@@ -420,6 +438,7 @@ export default {
       try {
         var userInfo = uni.getStorageSync('userInfo')
         this.nickname = (userInfo && userInfo.nickname) ? userInfo.nickname : ''
+        this.selfMemberId = uni.getStorageSync('userId') || null
 
         // 优先使用仪表盘接口(包含孩子列表 + 统计数据)
         let dashboardLoaded = false
@@ -636,16 +655,6 @@ export default {
       }
     },
     // 成员选择
-    onMemberSelect(member) {
-      if (member.memberType === 'all') {
-        this.selectedMemberId = 0
-        this.selectedMemberType = 'all'
-      } else {
-        this.selectedMemberId = member.id
-        this.selectedMemberType = member.memberType
-      }
-      // TODO: 根据选中成员重新加载对应维度数据
-    },
     goToCompletionStats() {
       if (this.children.length === 1) {
         uni.navigateTo({ url: '/pages/stats/completion-rate?childId=' + this.children[0].id })
@@ -664,6 +673,12 @@ export default {
         url: '/pages/energy/detail?childId=' + childId + '&code=' + code
       })
     },
+    goMemberDetail(member) {
+      if (!member || !member.memberId) return
+      uni.navigateTo({
+        url: '/pages/index/member-home-detail?memberId=' + member.memberId + '&memberType=' + (member.memberType || 'child')
+      })
+    },
     addTask() { uni.navigateTo({ url: '/pages/tasks/create-task' }) },
     goToReview() { uni.navigateTo({ url: '/pages/tasks/review' }) },
     goToWishApprove() { uni.navigateTo({ url: '/pages/wishes/approve' }) },

+ 81 - 56
cfc-frontend/pages/mind/index.vue

@@ -5,13 +5,21 @@
       tagline="心智成长 · 智慧人生"
       quote="知己知彼,百战不殆" />
 
-    <!-- 家庭成员切换条 -->
-    <FamilyMemberStrip
-      v-if="isLoggedIn && familyMembersVisible.length > 0"
-      :members="familyMembersVisible"
-      :selectedMemberId="selectedMemberId"
-      :selectedMemberType="selectedMemberType"
-      @select="onMemberSelect" />
+    <!-- ===== 心/智 Tab栏(最顶部) ===== -->
+    <view class="tab-bar">
+      <view
+        :class="['tab-item', activeTab === 'emotion' ? 'active' : '']"
+        @click="switchTab('emotion')">
+        <text class="tab-icon">&#x2764;</text>
+        <text class="tab-label">心·心理</text>
+      </view>
+      <view
+        :class="['tab-item', activeTab === 'cognitive' ? 'active' : '']"
+        @click="switchTab('cognitive')">
+        <text class="tab-icon">&#x1F9E0;</text>
+        <text class="tab-label">智·认知</text>
+      </view>
+    </view>
 
     <!-- 游客登录引导卡 -->
     <template v-if="!isLoggedIn">
@@ -24,6 +32,9 @@
         ]" />
     </template>
 
+    <!-- ===== 心·心理 Tab ===== -->
+    <template v-if="activeTab === 'emotion'">
+
     <!-- 登录后:五维能量条 -->
     <FamilyEnergyBar
       v-if="isLoggedIn"
@@ -51,25 +62,6 @@
       @childChanged="onChildChanged"
       @scrollTo="scrollToSection" />
 
-    <!-- ===== 心/智 Tab栏 ===== -->
-    <view class="tab-bar">
-      <view
-        :class="['tab-item', activeTab === 'emotion' ? 'active' : '']"
-        @click="switchTab('emotion')">
-        <text class="tab-icon">&#x2764;</text>
-        <text class="tab-label">心·心理</text>
-      </view>
-      <view
-        :class="['tab-item', activeTab === 'cognitive' ? 'active' : '']"
-        @click="switchTab('cognitive')">
-        <text class="tab-icon">&#x1F9E0;</text>
-        <text class="tab-label">智·认知</text>
-      </view>
-    </view>
-
-    <!-- ===== 心·心理 Tab ===== -->
-    <template v-if="activeTab === 'emotion'">
-
     <!-- ===== 心理能量概况 ===== -->
     <view class="energy-overview" v-if="emiData">
       <view class="energy-badge">
@@ -197,24 +189,35 @@
     <!-- ===== 智·认知 Tab ===== -->
     <template v-if="activeTab === 'cognitive'">
 
+    <!-- 登录后:用户快捷入口 -->
+    <UserQuickEntry
+      v-if="isLoggedIn"
+      :userName="userName"
+      roleName="家长"
+      :currentChildName="currentChildName"
+      :children="children"
+      @childChanged="onChildChanged"
+      @scrollTo="scrollToSection" />
+
     <!-- ===== 认知区块 ===== -->
-      <!-- 综合认知评分 -->
+      <!-- 认知总分明细 -->
+      <view class="cognitive-score-highlight" v-if="assessmentData">
+        <view class="score-big-cell">
+          <text class="score-big-label">认知总分</text>
+          <text class="score-big-value">{{ assessmentData.overallScore || 0 }}</text>
+        </view>
+        <view class="score-big-cell">
+          <text class="score-big-label">DAN等级</text>
+          <text class="score-big-value highlight">{{ assessmentData.danLevel || '-' }}</text>
+        </view>
+        <view class="score-big-cell">
+          <text class="score-big-label">上次测评</text>
+          <text class="score-big-value small">{{ assessmentData.assessmentDate || '-' }}</text>
+        </view>
+      </view>
+      <!-- 综合认知评分(操作入口) -->
       <view class="cognitive-overview" v-if="assessmentData">
         <view class="cognitive-score-card">
-          <view class="score-row">
-            <view class="score-cell">
-              <text class="score-label">DAN等级</text>
-              <text class="score-value highlight">{{ assessmentData.danLevel || '-' }}</text>
-            </view>
-            <view class="score-cell">
-              <text class="score-label">综合得分</text>
-              <text class="score-value">{{ assessmentData.overallScore || 0 }}</text>
-            </view>
-            <view class="score-cell">
-              <text class="score-label">上次测评</text>
-              <text class="score-value small">{{ assessmentData.assessmentDate || '-' }}</text>
-            </view>
-          </view>
           <view class="score-actions">
             <view class="score-link" @click="goCognitiveReport">
               <text class="score-link-text">查看完整报告</text>
@@ -387,13 +390,12 @@ import UserQuickEntry from '../../components/UserQuickEntry.vue'
 import DimensionTasks from '../../components/DimensionTasks.vue'
 import DimensionActivities from '../../components/DimensionActivities.vue'
 import DimensionProducts from '../../components/DimensionProducts.vue'
-import FamilyMemberStrip from '../../components/FamilyMemberStrip.vue'
 import FamilyRelationGraph from '../../components/FamilyRelationGraph.vue'
-import { getVisibleSections, getFeaturedArticles, getEmiReport, getAssessmentLatestResult, getDailyTip, getReadingStats, getFamilyEnergySandbox, getTodayTasksByCategory, getActivityList, getProductsByDomain, getChildren, getVisibleFamilyMembers } from '../../utils/api.js'
+import { getVisibleSections, getFeaturedArticles, getEmiReport, getAssessmentLatestResult, getDailyTip, getReadingStats, getFamilyEnergySandbox, getTodayTasksByCategory, getActivityList, getProductsByDomain, getChildren, getVisibleFamilyMembers, getEnergyOverview } from '../../utils/api.js'
 import config from '../../config.js'
 
 export default {
-  components: { PageBanner, RadarChart, LoginGuideCard, FamilyEnergyBar, UserQuickEntry, DimensionTasks, DimensionActivities, DimensionProducts, FamilyMemberStrip, FamilyRelationGraph },
+  components: { PageBanner, RadarChart, LoginGuideCard, FamilyEnergyBar, UserQuickEntry, DimensionTasks, DimensionActivities, DimensionProducts, FamilyRelationGraph },
   data() {
     return {
       isLoggedIn: false,
@@ -401,8 +403,6 @@ export default {
       currentChildId: null,
       visibleSections: [],
       familyMembersVisible: [],
-      selectedMemberId: 0,
-      selectedMemberType: 'all',
 
       // 心理 Tab 数据
       emiData: null,
@@ -880,16 +880,6 @@ export default {
         self.familyMembersVisible = []
       })
     },
-    onMemberSelect: function(member) {
-      if (member.memberType === 'all') {
-        this.selectedMemberId = 0
-        this.selectedMemberType = 'all'
-      } else {
-        this.selectedMemberId = member.id
-        this.selectedMemberType = member.memberType
-      }
-      // TODO: 根据选中成员重新加载对应维度数据
-    },
     scrollToSection: function(section) {
       uni.pageScrollTo({ selector: '#' + section, duration: 300 })
     },
@@ -1141,6 +1131,41 @@ export default {
   box-shadow: 0 4rpx 16rpx rgba(255,107,53,0.3);
 }
 
+/* ===== 认知总分明细 ===== */
+.cognitive-score-highlight {
+  display: flex;
+  flex-direction: row;
+  margin: 20rpx 30rpx;
+  background: #fff;
+  border-radius: 24rpx;
+  padding: 30rpx 16rpx;
+  box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.06);
+  justify-content: space-around;
+}
+.score-big-cell {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  flex: 1;
+}
+.score-big-label {
+  font-size: 22rpx;
+  color: #999;
+  margin-bottom: 10rpx;
+}
+.score-big-value {
+  font-size: 48rpx;
+  font-weight: bold;
+  color: #333;
+}
+.score-big-value.highlight {
+  color: #FFD700;
+}
+.score-big-value.small {
+  font-size: 22rpx;
+  color: #666;
+}
+
 /* ===== 认知评分卡 ===== */
 .cognitive-overview {
   margin: 20rpx 30rpx;

+ 42 - 1
cfc-frontend/pages/mind/member-mind-detail.vue

@@ -29,6 +29,15 @@
       </view>
     </view>
 
+    <!-- 家庭成员关系图谱(只读) -->
+    <FamilyRelationGraph
+      dimensionCode="mind"
+      :selfId="selfId"
+      :members="graphMembers"
+      :energyMap="energyMapForGraph"
+      :intimacyMap="intimacyMapForGraph"
+      :interactive="false" />
+
     <!-- 今日任务 -->
     <DimensionTasks
       dimensionCode="mind"
@@ -57,16 +66,18 @@
 
 <script>
 import FamilyEnergyBar from '../../components/FamilyEnergyBar.vue'
+import FamilyRelationGraph from '../../components/FamilyRelationGraph.vue'
 import DimensionTasks from '../../components/DimensionTasks.vue'
 import DimensionActivities from '../../components/DimensionActivities.vue'
 import DimensionProducts from '../../components/DimensionProducts.vue'
 import { getEnergyOverview, getFamilyEnergySandbox, getTodayTasksByCategory, getActivityList, getProductsByDomain, getChildren } from '../../utils/api.js'
 
 export default {
-  components: { FamilyEnergyBar, DimensionTasks, DimensionActivities, DimensionProducts },
+  components: { FamilyEnergyBar, FamilyRelationGraph, DimensionTasks, DimensionActivities, DimensionProducts },
   data() {
     return {
       childId: null,
+      selfId: null,
       memberInfo: null,
       sandboxData: null,
       dualDimension: null,
@@ -75,10 +86,40 @@ export default {
       dimensionProducts: []
     }
   },
+  computed: {
+    graphMembers: function() {
+      if (!this.sandboxData || !this.sandboxData.members) return []
+      return this.sandboxData.members.map(function(m) {
+        return {
+          id: m.memberId || m.id,
+          nickname: m.name || m.nickname || '成员',
+          memberType: m.memberType || 'child'
+        }
+      })
+    },
+    energyMapForGraph: function() {
+      var map = {}
+      if (this.sandboxData && this.sandboxData.members) {
+        for (var i = 0; i < this.sandboxData.members.length; i++) {
+          var m = this.sandboxData.members[i]
+          map[m.memberId || m.id] = {
+            bodyScore: m.bodyScore || 0,
+            mindScore: m.mindScore || 0,
+            actionScore: m.actionScore || 0
+          }
+        }
+      }
+      return map
+    },
+    intimacyMapForGraph: function() {
+      return {}
+    }
+  },
   onLoad: function(options) {
     this.childId = options.childId || uni.getStorageSync('currentChildId') || null
   },
   onShow: function() {
+    this.selfId = uni.getStorageSync('userId') || null
     this.loadChildren()
     this.loadSandboxData()
     if (this.childId) {

+ 2121 - 0
docs/superpowers/plans/2026-06-22-食谱推荐系统实现计划.md

@@ -0,0 +1,2121 @@
+# 食谱推荐系统 — 实现计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 基于个人健康情况、饮食喜好、家庭偏好、财力预算、应季食品和近期营养摄入,实现混合架构食谱推荐系统(DB 候选过滤 + Dify AI 个性化编排)
+
+**Architecture:** 新增 4 张表(foods/recipes/seasonal_foods/meal_logs)+ 扩展 user_nutrition_profile。后端新增 MealRecommendService 实现数据聚合→DB过滤→Dify编排三阶段推荐流程。一回换一菜走同品类随机替换(<100ms)。外部 API 仅作为数据填充工具,非运行时依赖。
+
+**Tech Stack:** Spring Boot 2.7.18 + MyBatis-Plus + MySQL 8.0 + Dify API + Spoonacular (批量导入) + uni-app 小程序 + Vue 2 Web 管理端
+
+---
+
+### File Structure
+
+```
+cfc-backend/src/main/java/com/etotem/cfc/
+├── entity/
+│   ├── Food.java                   # 食材实体 (NEW)
+│   ├── Recipe.java                 # 食谱实体 (NEW)
+│   ├── SeasonalFood.java           # 应季食材映射实体 (NEW)
+│   ├── MealLog.java                # 饮食日志实体 (NEW)
+│   └── UserNutritionProfile.java   # 扩展 budget_monthly 等字段 (MODIFY)
+├── mapper/
+│   ├── FoodMapper.java             # (NEW)
+│   ├── RecipeMapper.java           # (NEW)
+│   ├── SeasonalFoodMapper.java     # (NEW)
+│   └── MealLogMapper.java          # (NEW)
+├── service/
+│   ├── FoodService.java            # 食材 CRUD + 按季度/价格/标签查询 (NEW)
+│   ├── RecipeService.java          # 食谱 CRUD + 按餐型/场景查询 (NEW)
+│   ├── SeasonalFoodService.java    # 应季食材查询 (NEW)
+│   ├── MealLogService.java         # 饮食日志 CRUD + 7天营养统计 (NEW)
+│   └── MealRecommendService.java   # 核心推荐引擎 (NEW)
+├── controller/
+│   └── MealRecommendController.java # /api/meal/recommend + /api/meal/replace (NEW)
+├── dto/
+│   ├── RecommendationContext.java   # 聚合上下文 (NEW)
+│   └── MealRecommendResult.java     # 推荐结果 (NEW)
+├── config/
+│   └── DatabaseInitializer.java     # DDL 新增5个 ALTER/CREATE (MODIFY)
+
+cfc-frontend/pages/meal/
+├── recommend.vue                    # 食谱推荐首页 (NEW)
+├── recipe-detail.vue                # 食谱详情 (NEW)
+├── meal-log.vue                     # 饮食记录 (NEW)
+└── preference.vue                   # 偏好设置 (NEW)
+
+cfc-web/src/views/admin/
+├── foods/index.vue                  # 食材管理 (NEW)
+├── recipes/index.vue                # 食谱管理 (NEW)
+└── seasonal/index.vue               # 应季食材配置 (NEW)
+```
+
+---
+
+### Task 1: DDL — 新增 4 张表 + 扩展 user_nutrition_profile
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`
+
+- [ ] **Step 1: 在 DatabaseInitializer.java 新增建表块**
+
+定位到 `DatabaseInitializer.java` 末尾的建表区域(约 1436 行,`health_disease_risk` 之后),新增 `initFoodTables()` 方法并调用:
+
+```java
+private void initFoodTables() {
+    String[] ddl = {
+        "CREATE TABLE IF NOT EXISTS foods ("
+        + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
+        + "name VARCHAR(100) NOT NULL COMMENT '食材名称', "
+        + "category VARCHAR(50) COMMENT '分类: 蔬菜/水果/肉禽/水产/蛋奶/豆类/谷物/调味/其他', "
+        + "unit VARCHAR(20) DEFAULT 'g' COMMENT '计量单位', "
+        + "calories DECIMAL(8,2) COMMENT '热量(kcal/100g)', "
+        + "protein DECIMAL(8,2) COMMENT '蛋白质(g/100g)', "
+        + "fat DECIMAL(8,2) COMMENT '脂肪(g/100g)', "
+        + "carbs DECIMAL(8,2) COMMENT '碳水(g/100g)', "
+        + "fiber DECIMAL(8,2) COMMENT '膳食纤维(g/100g)', "
+        + "price_level TINYINT COMMENT '价格等级 1-5', "
+        + "nutrition_tags VARCHAR(500) COMMENT '营养标签JSON', "
+        + "suitable_for VARCHAR(200) COMMENT '适宜人群JSON', "
+        + "allergens VARCHAR(200) COMMENT '过敏原JSON', "
+        + "external_source VARCHAR(100) COMMENT '数据来源', "
+        + "external_id VARCHAR(100) COMMENT '外部ID', "
+        + "cover_image VARCHAR(500) COMMENT '食材图片', "
+        + "status VARCHAR(20) DEFAULT 'active', "
+        + "sort_order INT DEFAULT 0, "
+        + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
+        + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
+        + "INDEX idx_category (category), "
+        + "INDEX idx_price (price_level)"
+        + ");",
+
+        "CREATE TABLE IF NOT EXISTS recipes ("
+        + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
+        + "name VARCHAR(200) NOT NULL COMMENT '食谱名称', "
+        + "meal_type VARCHAR(20) COMMENT '早餐/午餐/晚餐/加餐', "
+        + "description TEXT COMMENT '简介', "
+        + "ingredients TEXT NOT NULL COMMENT '食材清单JSON', "
+        + "steps TEXT COMMENT '烹饪步骤JSON', "
+        + "cook_time INT COMMENT '烹饪时间(分钟)', "
+        + "difficulty VARCHAR(20) COMMENT '难度: 简单/中等/困难', "
+        + "total_calories DECIMAL(8,2) COMMENT '总热量', "
+        + "total_protein DECIMAL(8,2) COMMENT '总蛋白质', "
+        + "total_fat DECIMAL(8,2) COMMENT '总脂肪', "
+        + "total_carbs DECIMAL(8,2) COMMENT '总碳水', "
+        + "total_fiber DECIMAL(8,2) COMMENT '总膳食纤维', "
+        + "suitable_for VARCHAR(200) COMMENT '适宜场景', "
+        + "nutrition_tags VARCHAR(500) COMMENT '营养标签', "
+        + "external_source VARCHAR(100), "
+        + "external_id VARCHAR(100), "
+        + "cover_image VARCHAR(500), "
+        + "status VARCHAR(20) DEFAULT 'active', "
+        + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
+        + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
+        + "INDEX idx_meal_type (meal_type), "
+        + "INDEX idx_cook_time (cook_time)"
+        + ");",
+
+        "CREATE TABLE IF NOT EXISTS seasonal_foods ("
+        + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
+        + "food_id BIGINT NOT NULL COMMENT '关联foods.id', "
+        + "month TINYINT NOT NULL COMMENT '月份 1-12', "
+        + "region VARCHAR(50) DEFAULT '全国' COMMENT '产地', "
+        + "peak_flag TINYINT DEFAULT 0 COMMENT '盛产标志', "
+        + "sort_order INT DEFAULT 0, "
+        + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
+        + "INDEX idx_food_month (food_id, month), "
+        + "INDEX idx_month (month)"
+        + ");",
+
+        "CREATE TABLE IF NOT EXISTS meal_logs ("
+        + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
+        + "user_id BIGINT NOT NULL COMMENT '用户ID', "
+        + "child_id BIGINT COMMENT '孩子ID', "
+        + "meal_type VARCHAR(20) NOT NULL COMMENT '早餐/午餐/晚餐/加餐', "
+        + "meal_date DATE NOT NULL COMMENT '用餐日期', "
+        + "meal_time DATETIME COMMENT '具体时间', "
+        + "foods TEXT NOT NULL COMMENT '食物清单JSON', "
+        + "total_calories DECIMAL(8,2) COMMENT '总热量', "
+        + "total_protein DECIMAL(8,2) COMMENT '总蛋白质', "
+        + "total_fat DECIMAL(8,2) COMMENT '总脂肪', "
+        + "total_carbs DECIMAL(8,2) COMMENT '总碳水', "
+        + "total_fiber DECIMAL(8,2) COMMENT '总膳食纤维', "
+        + "note TEXT COMMENT '备注', "
+        + "images VARCHAR(1000) COMMENT '图片JSON', "
+        + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
+        + "INDEX idx_user_date (user_id, meal_date), "
+        + "INDEX idx_child_date (child_id, meal_date)"
+        + ");",
+
+        // 扩展 user_nutrition_profile(如果表不存在则先检查,用 ALTER 加列)
+        "ALTER TABLE user_nutrition_profile ADD COLUMN IF NOT EXISTS budget_monthly INT COMMENT '月食品预算(元)'",
+        "ALTER TABLE user_nutrition_profile ADD COLUMN IF NOT EXISTS family_taste_preferences TEXT COMMENT '家庭成员口味偏好JSON'",
+        "ALTER TABLE user_nutrition_profile ADD COLUMN IF NOT EXISTS cuisine_style VARCHAR(100) COMMENT '偏好菜系'",
+        "ALTER TABLE user_nutrition_profile ADD COLUMN IF NOT EXISTS meal_count INT DEFAULT 3 COMMENT '每日餐数'",
+        "ALTER TABLE user_nutrition_profile ADD COLUMN IF NOT EXISTS cooking_ability VARCHAR(20) DEFAULT 'simple' COMMENT '烹饪能力'"
+    };
+
+    for (String sql : ddl) {
+        try {
+            jdbcTemplate.execute(sql);
+        } catch (Exception e) {
+            log.warn("食品表DDL执行失败(可能已存在): {}", e.getMessage());
+        }
+    }
+}
+```
+
+> **注意:** MySQL 8.0 不支持 `ADD COLUMN IF NOT EXISTS`。需用 try/catch 捕获重复列错误替代。或者先查 INFORMATION_SCHEMA.COLUMNS 判断。替代实现:
+
+```java
+private void ensureColumn(String table, String column, String definition) {
+    try {
+        jdbcTemplate.execute("ALTER TABLE " + table + " ADD COLUMN " + column + " " + definition);
+    } catch (Exception e) {
+        if (e.getMessage() != null && e.getMessage().contains("Duplicate column")) {
+            log.info("列 {}.{} 已存在,跳过", table, column);
+        } else {
+            log.warn("添加列 {}.{} 失败: {}", table, column, e.getMessage());
+        }
+    }
+}
+
+// 调用:
+ensureColumn("user_nutrition_profile", "budget_monthly", "INT COMMENT '月食品预算(元)'");
+ensureColumn("user_nutrition_profile", "family_taste_preferences", "TEXT COMMENT '家庭成员口味偏好JSON'");
+ensureColumn("user_nutrition_profile", "cuisine_style", "VARCHAR(100) COMMENT '偏好菜系'");
+ensureColumn("user_nutrition_profile", "meal_count", "INT DEFAULT 3 COMMENT '每日餐数'");
+ensureColumn("user_nutrition_profile", "cooking_ability", "VARCHAR(20) DEFAULT 'simple' COMMENT '烹饪能力'");
+```
+
+调用位置:在 `run()` 方法末尾或者 `initFoodTables()` 从 `run()` 调用。
+
+- [ ] **Step 2: 验证编译通过**
+
+Run: `cd cfc-backend && mvn clean compile -q`
+Expected: BUILD SUCCESS
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
+git commit -m "feat(db): add foods/recipes/seasonal_foods/meal_logs tables and extend user_nutrition_profile"
+```
+
+---
+
+### Task 2: Entity 类 — Food, Recipe, SeasonalFood, MealLog
+
+**Files:**
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/entity/Food.java`
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/entity/Recipe.java`
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/entity/SeasonalFood.java`
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/entity/MealLog.java`
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/entity/UserNutritionProfile.java`
+
+- [ ] **Step 1: 创建 Food.java**
+
+```java
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@TableName("foods")
+public class Food implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private String name;
+    private String category;
+    private String unit;
+    private BigDecimal calories;
+    private BigDecimal protein;
+    private BigDecimal fat;
+    private BigDecimal carbs;
+    private BigDecimal fiber;
+    private Integer priceLevel;
+    private String nutritionTags;
+    private String suitableFor;
+    private String allergens;
+    private String externalSource;
+    private String externalId;
+    private String coverImage;
+    private String status;
+    private Integer sortOrder;
+    private Date createdAt;
+    private Date updatedAt;
+}
+```
+
+- [ ] **Step 2: 创建 Recipe.java**
+
+```java
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@TableName("recipes")
+public class Recipe implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private String name;
+    private String mealType;
+    private String description;
+    private String ingredients;      // JSON
+    private String steps;            // JSON
+    private Integer cookTime;
+    private String difficulty;
+    private BigDecimal totalCalories;
+    private BigDecimal totalProtein;
+    private BigDecimal totalFat;
+    private BigDecimal totalCarbs;
+    private BigDecimal totalFiber;
+    private String suitableFor;
+    private String nutritionTags;
+    private String externalSource;
+    private String externalId;
+    private String coverImage;
+    private String status;
+    private Date createdAt;
+    private Date updatedAt;
+}
+```
+
+- [ ] **Step 3: 创建 SeasonalFood.java**
+
+```java
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("seasonal_foods")
+public class SeasonalFood implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long foodId;
+    private Integer month;
+    private String region;
+    private Integer peakFlag;
+    private Integer sortOrder;
+    private Date createdAt;
+}
+```
+
+- [ ] **Step 4: 创建 MealLog.java**
+
+```java
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@TableName("meal_logs")
+public class MealLog implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long userId;
+    private Long childId;
+    private String mealType;
+    private Date mealDate;
+    private Date mealTime;
+    private String foods;           // JSON
+    private BigDecimal totalCalories;
+    private BigDecimal totalProtein;
+    private BigDecimal totalFat;
+    private BigDecimal totalCarbs;
+    private BigDecimal totalFiber;
+    private String note;
+    private String images;          // JSON
+    private Date createdAt;
+}
+```
+
+- [ ] **Step 5: 扩展 UserNutritionProfile.java**
+
+在现有 `UserNutritionProfile.java` 末尾(`questionnaireStatus` 字段后)新增字段:
+
+```java
+    /** 月食品预算(元) */
+    private Integer budgetMonthly;
+
+    /** 家庭成员口味偏好JSON */
+    private String familyTastePreferences;
+
+    /** 偏好菜系 */
+    private String cuisineStyle;
+
+    /** 每日餐数(默认3) */
+    private Integer mealCount;
+
+    /** 烹饪能力: simple/medium/advanced */
+    private String cookingAbility;
+```
+
+- [ ] **Step 6: 验证编译通过**
+
+Run: `cd cfc-backend && mvn clean compile -q`
+Expected: BUILD SUCCESS
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/Food.java \
+       cfc-backend/src/main/java/com/etotem/cfc/entity/Recipe.java \
+       cfc-backend/src/main/java/com/etotem/cfc/entity/SeasonalFood.java \
+       cfc-backend/src/main/java/com/etotem/cfc/entity/MealLog.java \
+       cfc-backend/src/main/java/com/etotem/cfc/entity/UserNutritionProfile.java
+git commit -m "feat(entity): add Food/Recipe/SeasonalFood/MealLog entities and extend UserNutritionProfile"
+```
+
+---
+
+### Task 3: Mapper 接口
+
+**Files:**
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/mapper/FoodMapper.java`
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/mapper/RecipeMapper.java`
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/mapper/SeasonalFoodMapper.java`
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/mapper/MealLogMapper.java`
+
+- [ ] **Step 1~4: 创建 4 个 Mapper 接口**
+
+每个文件格式统一(以 FoodMapper 为例):
+
+```java
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.Food;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface FoodMapper extends BaseMapper<Food> {
+}
+```
+
+同样创建 `RecipeMapper`(BaseMapper\<Recipe\>)、`SeasonalFoodMapper`(BaseMapper\<SeasonalFood\>)、`MealLogMapper`(BaseMapper\<MealLog\>)。
+
+- [ ] **Step 5: 验证编译**
+
+Run: `cd cfc-backend && mvn clean compile -q`
+Expected: BUILD SUCCESS
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/mapper/FoodMapper.java \
+       cfc-backend/src/main/java/com/etotem/cfc/mapper/RecipeMapper.java \
+       cfc-backend/src/main/java/com/etotem/cfc/mapper/SeasonalFoodMapper.java \
+       cfc-backend/src/main/java/com/etotem/cfc/mapper/MealLogMapper.java
+git commit -m "feat(mapper): add Food/Recipe/SeasonalFood/MealLog mappers"
+```
+
+---
+
+### Task 4: 基础 Service — FoodService / RecipeService / SeasonalFoodService
+
+**Files:**
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/service/FoodService.java`
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/service/RecipeService.java`
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/service/SeasonalFoodService.java`
+
+- [ ] **Step 1: 创建 FoodService**
+
+```java
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.Food;
+import com.etotem.cfc.mapper.FoodMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.List;
+
+@Slf4j
+@Service
+public class FoodService {
+
+    @Resource
+    private FoodMapper foodMapper;
+
+    public List<Food> listAll(String category, String status) {
+        LambdaQueryWrapper<Food> wrapper = new LambdaQueryWrapper<Food>()
+                .eq(status != null, Food::getStatus, status)
+                .eq(category != null, Food::getCategory, category)
+                .orderByAsc(Food::getSortOrder);
+        return foodMapper.selectList(wrapper);
+    }
+
+    /**
+     * 按价格等级上限查询可用食材 (price_level <= maxLevel)
+     */
+    public List<Food> listByPriceLevel(int maxLevel) {
+        return foodMapper.selectList(
+                new LambdaQueryWrapper<Food>()
+                        .eq(Food::getStatus, "active")
+                        .le(Food::getPriceLevel, maxLevel)
+        );
+    }
+
+    public Food getById(Long id) {
+        return foodMapper.selectById(id);
+    }
+
+    public void create(Food food) {
+        foodMapper.insert(food);
+    }
+
+    public void update(Food food) {
+        foodMapper.updateById(food);
+    }
+
+    public void delete(Long id) {
+        foodMapper.deleteById(id);
+    }
+}
+```
+
+- [ ] **Step 2: 创建 RecipeService**
+
+```java
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.Recipe;
+import com.etotem.cfc.mapper.RecipeMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.List;
+
+@Slf4j
+@Service
+public class RecipeService {
+
+    @Resource
+    private RecipeMapper recipeMapper;
+
+    public List<Recipe> listByMealType(String mealType) {
+        return recipeMapper.selectList(
+                new LambdaQueryWrapper<Recipe>()
+                        .eq(Recipe::getStatus, "active")
+                        .eq(mealType != null, Recipe::getMealType, mealType)
+        );
+    }
+
+    public Recipe getById(Long id) {
+        return recipeMapper.selectById(id);
+    }
+
+    public void create(Recipe recipe) {
+        recipeMapper.insert(recipe);
+    }
+
+    public void update(Recipe recipe) {
+        recipeMapper.updateById(recipe);
+    }
+
+    public void delete(Long id) {
+        recipeMapper.deleteById(id);
+    }
+}
+```
+
+- [ ] **Step 3: 创建 SeasonalFoodService**
+
+```java
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.SeasonalFood;
+import com.etotem.cfc.mapper.SeasonalFoodMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.time.LocalDate;
+import java.util.List;
+
+@Slf4j
+@Service
+public class SeasonalFoodService {
+
+    @Resource
+    private SeasonalFoodMapper seasonalFoodMapper;
+
+    /**
+     * 获取当前月份的应季食材 ID 列表
+     */
+    public List<Long> getCurrentMonthFoodIds() {
+        int month = LocalDate.now().getMonthValue();
+        return getFoodIdsByMonth(month);
+    }
+
+    public List<Long> getFoodIdsByMonth(int month) {
+        return seasonalFoodMapper.selectList(
+                new LambdaQueryWrapper<SeasonalFood>()
+                        .eq(SeasonalFood::getMonth, month)
+                        .orderByDesc(SeasonalFood::getPeakFlag)
+        ).stream().map(SeasonalFood::getFoodId).distinct().toList();
+    }
+
+    public List<SeasonalFood> listByMonth(int month) {
+        return seasonalFoodMapper.selectList(
+                new LambdaQueryWrapper<SeasonalFood>()
+                        .eq(SeasonalFood::getMonth, month)
+                        .orderByDesc(SeasonalFood::getPeakFlag)
+        );
+    }
+
+    public void create(SeasonalFood sf) {
+        seasonalFoodMapper.insert(sf);
+    }
+
+    public void delete(Long id) {
+        seasonalFoodMapper.deleteById(id);
+    }
+}
+```
+
+- [ ] **Step 4: 验证编译**
+
+Run: `cd cfc-backend && mvn clean compile -q`
+Expected: BUILD SUCCESS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/FoodService.java \
+       cfc-backend/src/main/java/com/etotem/cfc/service/RecipeService.java \
+       cfc-backend/src/main/java/com/etotem/cfc/service/SeasonalFoodService.java
+git commit -m "feat(service): add FoodService/RecipeService/SeasonalFoodService CRUD"
+```
+
+---
+
+### Task 5: MealLogService — 饮食日志 + 营养统计
+
+**Files:**
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/service/MealLogService.java`
+
+- [ ] **Step 1: 创建 MealLogService**
+
+```java
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.MealLog;
+import com.etotem.cfc.mapper.MealLogMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.*;
+
+@Slf4j
+@Service
+public class MealLogService {
+
+    @Resource
+    private MealLogMapper mealLogMapper;
+
+    /**
+     * 记录一餐
+     */
+    public MealLog create(MealLog logEntry) {
+        mealLogMapper.insert(logEntry);
+        return logEntry;
+    }
+
+    /**
+     * 获取用户最近 N 天的饮食日志
+     */
+    public List<MealLog> getRecentMeals(Long userId, int days) {
+        LocalDate since = LocalDate.now().minusDays(days);
+        return mealLogMapper.selectList(
+                new LambdaQueryWrapper<MealLog>()
+                        .eq(MealLog::getUserId, userId)
+                        .ge(MealLog::getMealDate, java.sql.Date.valueOf(since))
+                        .orderByDesc(MealLog::getMealDate)
+        );
+    }
+
+    /**
+     * 获取用户最近 N 天的营养摄入统计
+     */
+    public Map<String, Object> getNutritionSummary(Long userId, int days) {
+        List<MealLog> logs = getRecentMeals(userId, days);
+
+        BigDecimal sumCalories = BigDecimal.ZERO;
+        BigDecimal sumProtein = BigDecimal.ZERO;
+        BigDecimal sumFat = BigDecimal.ZERO;
+        BigDecimal sumCarbs = BigDecimal.ZERO;
+        BigDecimal sumFiber = BigDecimal.ZERO;
+
+        for (MealLog log : logs) {
+            if (log.getTotalCalories() != null) sumCalories = sumCalories.add(log.getTotalCalories());
+            if (log.getTotalProtein() != null) sumProtein = sumProtein.add(log.getTotalProtein());
+            if (log.getTotalFat() != null) sumFat = sumFat.add(log.getTotalFat());
+            if (log.getTotalCarbs() != null) sumCarbs = sumCarbs.add(log.getTotalCarbs());
+            if (log.getTotalFiber() != null) sumFiber = sumFiber.add(log.getTotalFiber());
+        }
+
+        Map<String, Object> summary = new LinkedHashMap<>();
+        int dayCount = Math.max(days, 1);
+        summary.put("days", logs.isEmpty() ? 0 : days);
+        summary.put("avgCalories", logs.isEmpty() ? 0 : sumCalories.divide(BigDecimal.valueOf(dayCount), 0, BigDecimal.ROUND_HALF_UP));
+        summary.put("avgProtein", logs.isEmpty() ? 0 : sumProtein.divide(BigDecimal.valueOf(dayCount), 1, BigDecimal.ROUND_HALF_UP));
+        summary.put("avgFat", logs.isEmpty() ? 0 : sumFat.divide(BigDecimal.valueOf(dayCount), 1, BigDecimal.ROUND_HALF_UP));
+        summary.put("avgCarbs", logs.isEmpty() ? 0 : sumCarbs.divide(BigDecimal.valueOf(dayCount), 1, BigDecimal.ROUND_HALF_UP));
+        summary.put("avgFiber", logs.isEmpty() ? 0 : sumFiber.divide(BigDecimal.valueOf(dayCount), 1, BigDecimal.ROUND_HALF_UP));
+        summary.put("totalMeals", logs.size());
+
+        return summary;
+    }
+
+    /**
+     * 删除日志
+     */
+    public void delete(Long id) {
+        mealLogMapper.deleteById(id);
+    }
+}
+```
+
+- [ ] **Step 2: 验证编译**
+
+Run: `cd cfc-backend && mvn clean compile -q`
+Expected: BUILD SUCCESS
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/MealLogService.java
+git commit -m "feat(service): add MealLogService with meal logging and 7-day nutrition summary"
+```
+
+---
+
+### Task 6: DTO 类 — RecommendationContext + MealRecommendResult
+
+**Files:**
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/dto/RecommendationContext.java`
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/dto/MealRecommendResult.java`
+
+- [ ] **Step 1: 创建 RecommendationContext.java**
+
+```java
+package com.etotem.cfc.dto;
+
+import com.etotem.cfc.entity.*;
+import lombok.Data;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 食谱推荐上下文 — 聚合用户/健康/偏好/预算/饮食日志/候选食材
+ */
+@Data
+public class RecommendationContext {
+    private Long userId;
+    // 健康数据
+    private HealthReport latestReport;
+    private List<HealthIndicator> abnormalIndicators;
+    // 偏好预算
+    private Integer budgetMonthly;
+    private String tastePreference;
+    private String dietaryRestrictions;
+    private String cuisineStyle;
+    private Integer cookingAbility;
+    // 家庭偏好
+    private String familyTastePreferences;
+    // 近期摄入统计
+    private Map<String, Object> nutritionSummary;
+    // 候选集 (由 DB 过滤后填充)
+    private List<Food> candidateFoods;
+    private List<Recipe> candidateRecipes;
+}
+```
+
+- [ ] **Step 2: 创建 MealRecommendResult.java**
+
+```java
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 食谱推荐结果
+ */
+@Data
+public class MealRecommendResult {
+    private String weekPlan;            // Dify 返回的周食谱方案 (JSON)
+    private Map<String, Object> nutritionSummary;  // 营养素统计
+    private List<Food> replaceOptions;  // 可替换食材 (一回换一菜用)
+    private String forbiddenHint;       // 禁忌提示
+    private String nutritionTip;        // 营养建议
+}
+```
+
+- [ ] **Step 3: 验证编译**
+
+Run: `cd cfc-backend && mvn clean compile -q`
+Expected: BUILD SUCCESS
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/dto/RecommendationContext.java \
+       cfc-backend/src/main/java/com/etotem/cfc/dto/MealRecommendResult.java
+git commit -m "feat(dto): add RecommendationContext and MealRecommendResult DTOs"
+```
+
+---
+
+### Task 7: MealRecommendService — 核心推荐引擎
+
+**Files:**
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/service/MealRecommendService.java`
+
+- [ ] **Step 1: 创建 MealRecommendService**
+
+```java
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.dto.MealRecommendResult;
+import com.etotem.cfc.dto.RecommendationContext;
+import com.etotem.cfc.entity.*;
+import com.etotem.cfc.mapper.*;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.*;
+import java.util.stream.Collectors;
+
+@Slf4j
+@Service
+public class MealRecommendService {
+
+    @Resource
+    private HealthReportService healthReportService;
+
+    @Resource
+    private FoodService foodService;
+
+    @Resource
+    private RecipeService recipeService;
+
+    @Resource
+    private SeasonalFoodService seasonalFoodService;
+
+    @Resource
+    private MealLogService mealLogService;
+
+    @Resource
+    private UserMapper userMapper;
+
+    @Resource
+    private ReportSurveyService reportSurveyService;
+
+    /**
+     * 聚合用户上下文
+     */
+    public RecommendationContext aggregateContext(Long userId) {
+        RecommendationContext ctx = new RecommendationContext();
+        ctx.setUserId(userId);
+
+        // 1. 健康报告
+        HealthReport report = healthReportService.getLatestReport(userId);
+        ctx.setLatestReport(report);
+        if (report != null) {
+            ctx.setAbnormalIndicators(
+                healthReportService.getReportIndicators(report.getId()).stream()
+                    .filter(i -> i.getStatus() != null && !"正常".equals(i.getStatus()))
+                    .collect(Collectors.toList())
+            );
+        }
+
+        // 2. 偏好预算 (从 user_nutrition_profile 或 user 表读取)
+        // TODO: 待 UserNutritionProfileService 实现后读取实际数据
+        ctx.setBudgetMonthly(2000);  // 默认预算,后续替换
+        ctx.setTastePreference("清淡");
+        ctx.setDietaryRestrictions("无");
+        ctx.setCuisineStyle("中式");
+
+        // 3. 家庭口味偏好
+        // TODO: 从 user_nutrition_profile.family_taste_preferences 读取
+
+        // 4. 近期营养摄入
+        ctx.setNutritionSummary(mealLogService.getNutritionSummary(userId, 7));
+
+        // 5. 候选集过滤
+        filterCandidates(ctx);
+
+        return ctx;
+    }
+
+    /**
+     * DB 候选集过滤(应季 + 预算 + 无过敏 + 健康标签匹配)
+     */
+    private void filterCandidates(RecommendationContext ctx) {
+        // 预算等级映射: 月预算/500 ≈ price_level 上限
+        int maxPriceLevel = 1;
+        if (ctx.getBudgetMonthly() != null) {
+            maxPriceLevel = Math.max(1, Math.min(5, ctx.getBudgetMonthly() / 500));
+        }
+
+        // 当月应季食材 ID
+        List<Long> seasonalIds = seasonalFoodService.getCurrentMonthFoodIds();
+
+        // 查询应季 + 预算内食材
+        List<Food> allActive = foodService.listAll(null, "active");
+        List<Food> candidates = allActive.stream()
+                .filter(f -> seasonalIds.contains(f.getId()))
+                .filter(f -> f.getPriceLevel() != null && f.getPriceLevel() <= maxPriceLevel)
+                .collect(Collectors.toList());
+
+        ctx.setCandidateFoods(candidates.size() > 30 ? candidates.subList(0, 30) : candidates);
+
+        // 候选食谱
+        List<Recipe> recipeCandidates = recipeService.listByMealType(null);
+        ctx.setCandidateRecipes(recipeCandidates.size() > 15 ? recipeCandidates.subList(0, 15) : recipeCandidates);
+    }
+
+    /**
+     * 一回换一菜 — 同品类随机替换
+     */
+    public Food findReplaceCandidate(Long currentFoodId, Long userId) {
+        RecommendationContext ctx = aggregateContext(userId);
+        Food current = foodService.getById(currentFoodId);
+        if (current == null) return null;
+
+        return ctx.getCandidateFoods().stream()
+                .filter(f -> !f.getId().equals(currentFoodId))
+                .filter(f -> current.getCategory() == null || current.getCategory().equals(f.getCategory()))
+                .filter(f -> f.getPriceLevel() != null && current.getPriceLevel() != null
+                        && Math.abs(f.getPriceLevel() - current.getPriceLevel()) <= 1)
+                .findAny()
+                .orElse(null);
+    }
+
+    /**
+     * 构建 Dify inputs(供 Controller 调用 AI 时传入)
+     */
+    public Map<String, Object> buildDifyInputs(Long userId) {
+        RecommendationContext ctx = aggregateContext(userId);
+        Map<String, Object> inputs = new LinkedHashMap<>();
+
+        // 用户摘要
+        Map<String, Object> userProfile = new LinkedHashMap<>();
+        userProfile.put("taste", ctx.getTastePreference());
+        userProfile.put("cuisine", ctx.getCuisineStyle());
+        userProfile.put("budget", ctx.getBudgetMonthly());
+        userProfile.put("dietaryRestrictions", ctx.getDietaryRestrictions());
+        inputs.put("user_profile", userProfile);
+
+        // 健康摘要
+        if (ctx.getLatestReport() != null) {
+            Map<String, Object> healthSummary = new LinkedHashMap<>();
+            HealthReport r = ctx.getLatestReport();
+            healthSummary.put("overallScore", r.getOverallScore());
+            healthSummary.put("gutType", r.getGutType());
+            healthSummary.put("abnormalIndicators",
+                ctx.getAbnormalIndicators() != null
+                    ? ctx.getAbnormalIndicators().stream().map(i ->
+                        Map.of("name", i.getIndicatorName(), "value", i.getIndicatorValue(),
+                               "status", i.getStatus(), "refRange", i.getRefRange())).toList()
+                    : List.of());
+            inputs.put("health_summary", healthSummary);
+        }
+
+        // 近期摄入
+        inputs.put("recent_intake", ctx.getNutritionSummary() != null ? ctx.getNutritionSummary() : Map.of());
+
+        // 候选食材(简化为名称列表)
+        inputs.put("candidate_foods",
+            ctx.getCandidateFoods().stream()
+                .map(f -> Map.of("name", f.getName(), "category", f.getCategory(),
+                                 "priceLevel", f.getPriceLevel(), "nutritionTags", f.getNutritionTags()))
+                .toList());
+
+        // 候选食谱
+        inputs.put("candidate_recipes",
+            ctx.getCandidateRecipes().stream()
+                .map(r -> Map.of("name", r.getName(), "mealType", r.getMealType(),
+                                 "cookTime", r.getCookTime(), "nutritionTags", r.getNutritionTags()))
+                .toList());
+
+        return inputs;
+    }
+}
+```
+
+> **注意:** `aggregateContext` 中的偏好/预算读取标记了 TODO,待 `UserNutritionProfileService` 实现后替换硬编码值。当前硬编码默认值(预算 2000,口味清淡)确保 API 可测试。
+
+- [ ] **Step 2: 验证编译**
+
+Run: `cd cfc-backend && mvn clean compile -q`
+Expected: BUILD SUCCESS
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/MealRecommendService.java
+git commit -m "feat(service): add MealRecommendService with DB filtering and Dify input building"
+```
+
+---
+
+### Task 8: MealRecommendController — REST API
+
+**Files:**
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/controller/MealRecommendController.java`
+
+- [ ] **Step 1: 创建 MealRecommendController**
+
+```java
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.MealRecommendResult;
+import com.etotem.cfc.dto.RecommendationContext;
+import com.etotem.cfc.entity.Food;
+import com.etotem.cfc.service.*;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.*;
+
+/**
+ * 食谱推荐接口
+ */
+@Tag(name = "食谱推荐", description = "个性化食谱推荐与饮食记录")
+@Slf4j
+@RestController
+@RequestMapping("/api/meal")
+public class MealRecommendController {
+
+    @Resource
+    private MealRecommendService mealRecommendService;
+
+    @Resource
+    private MealLogService mealLogService;
+
+    @Resource
+    private AIService aiService;
+
+    @Operation(summary = "获取食谱推荐")
+    @PostMapping("/recommend")
+    public Result<Map<String, Object>> recommend(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, Object> params) {
+        try {
+            // 1. 聚合上下文 + 过滤候选
+            RecommendationContext ctx = mealRecommendService.aggregateContext(userId);
+            if (ctx.getCandidateFoods().isEmpty() && ctx.getCandidateRecipes().isEmpty()) {
+                return Result.error("暂无可用食材和食谱,请联系运营配置");
+            }
+
+            // 2. 构建 Dify inputs
+            String query = "请根据我的健康情况和候选食材,生成一份个性化的" +
+                    (params.get("mealType") != null ? params.get("mealType") + " " : "每日")
+                    + "食谱方案。格式要求:返回JSON包含dailyMeals数组(每餐含recipeName/reason/ingredients/tips)、forbiddenHint、nutritionTip。";
+            String conversationId = (String) params.get("conversationId");
+
+            Map<String, Object> inputs = mealRecommendService.buildDifyInputs(userId);
+            Map<String, Object> difyResp = aiService.sendNutritionMessage(
+                    query, String.valueOf(userId), conversationId, inputs);
+            String answer = (String) difyResp.getOrDefault("answer", "");
+
+            // 3. 组装结果
+            Map<String, Object> result = new LinkedHashMap<>();
+            result.put("answer", answer);
+            result.put("conversationId", difyResp.getOrDefault("conversationId", ""));
+            result.put("replaceOptions", ctx.getCandidateFoods().size() > 5
+                    ? ctx.getCandidateFoods().subList(0, 5) : ctx.getCandidateFoods());
+            result.put("nutritionSummary", ctx.getNutritionSummary());
+
+            return Result.success(result);
+        } catch (Exception e) {
+            log.error("食谱推荐失败: userId={}", userId, e);
+            return Result.error("食谱推荐失败: " + e.getMessage());
+        }
+    }
+
+    @Operation(summary = "一回换一菜 — 替换指定食材")
+    @PostMapping("/replace")
+    public Result<Food> replace(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, Object> params) {
+        Long foodId = params.get("foodId") != null
+                ? Long.valueOf(params.get("foodId").toString()) : null;
+        if (foodId == null) {
+            return Result.error("foodId不能为空");
+        }
+        Food replacement = mealRecommendService.findReplaceCandidate(foodId, userId);
+        if (replacement == null) {
+            return Result.error("没有可替换的食材");
+        }
+        return Result.success(replacement);
+    }
+
+    @Operation(summary = "记录饮食日志")
+    @PostMapping("/log")
+    public Result<Map<String, Object>> createLog(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, Object> params) {
+        // 前端传: { childId?, mealType, mealDate, foods (JSON), note? }
+        com.etotem.cfc.entity.MealLog logEntry = new com.etotem.cfc.entity.MealLog();
+        logEntry.setUserId(userId);
+        if (params.get("childId") != null) {
+            logEntry.setChildId(Long.valueOf(params.get("childId").toString()));
+        }
+        logEntry.setMealType((String) params.get("mealType"));
+        logEntry.setFoods(params.get("foods") != null ? params.get("foods").toString() : "[]");
+        logEntry.setNote((String) params.get("note"));
+        // 解析日期
+        Object dateObj = params.get("mealDate");
+        if (dateObj != null) {
+            try {
+                java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd");
+                logEntry.setMealDate(sdf.parse(dateObj.toString()));
+            } catch (Exception e) {
+                logEntry.setMealDate(new Date());
+            }
+        } else {
+            logEntry.setMealDate(new Date());
+        }
+        // TODO: 营养素自动计算 — 根据 foods JSON 中的 foodId 查 Food 表累计
+        logEntry.setTotalCalories(null);
+        logEntry.setTotalProtein(null);
+        logEntry.setTotalFat(null);
+        logEntry.setTotalCarbs(null);
+        logEntry.setTotalFiber(null);
+
+        mealLogService.create(logEntry);
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("id", logEntry.getId());
+        result.put("message", "记录成功");
+        return Result.success(result);
+    }
+
+    @Operation(summary = "获取近期饮食日志")
+    @PostMapping("/logs")
+    public Result<List<com.etotem.cfc.entity.MealLog>> getLogs(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, Object> params) {
+        int days = params.get("days") != null
+                ? Integer.parseInt(params.get("days").toString()) : 7;
+        return Result.success(mealLogService.getRecentMeals(userId, days));
+    }
+
+    @Operation(summary = "获取营养摄入统计")
+    @PostMapping("/nutrition-summary")
+    public Result<Map<String, Object>> nutritionSummary(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, Object> params) {
+        int days = params.get("days") != null
+                ? Integer.parseInt(params.get("days").toString()) : 7;
+        return Result.success(mealLogService.getNutritionSummary(userId, days));
+    }
+}
+```
+
+- [ ] **Step 2: 验证编译**
+
+Run: `cd cfc-backend && mvn clean compile -q`
+Expected: BUILD SUCCESS
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/MealRecommendController.java
+git commit -m "feat(controller): add MealRecommendController with recommend/replace/log APIs"
+```
+
+---
+
+### Task 9: Admin 后端 Controller — 食材/食谱/应季 CRUD
+
+**Files:**
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminFoodController.java`
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminRecipeController.java`
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminSeasonalController.java`
+
+- [ ] **Step 1: 创建 AdminFoodController**
+
+```java
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.Food;
+import com.etotem.cfc.service.FoodService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@Tag(name = "管理端-食材管理")
+@RestController
+@RequestMapping("/api/admin/foods")
+public class AdminFoodController {
+
+    @Resource
+    private FoodService foodService;
+
+    @Operation(summary = "食材列表")
+    @PostMapping("/list")
+    public Result<List<Food>> list(@RequestBody Map<String, Object> params) {
+        String category = (String) params.get("category");
+        return Result.success(foodService.listAll(category, "active"));
+    }
+
+    @Operation(summary = "新增食材")
+    @PostMapping("/create")
+    public Result<String> create(@RequestBody Food food) {
+        foodService.create(food);
+        return Result.success("ok");
+    }
+
+    @Operation(summary = "更新食材")
+    @PostMapping("/update")
+    public Result<String> update(@RequestBody Food food) {
+        foodService.update(food);
+        return Result.success("ok");
+    }
+
+    @Operation(summary = "删除食材")
+    @PostMapping("/delete")
+    public Result<String> delete(@RequestBody Map<String, Object> params) {
+        Long id = params.get("id") != null ? Long.valueOf(params.get("id").toString()) : null;
+        if (id == null) return Result.error("id不能为空");
+        foodService.delete(id);
+        return Result.success("ok");
+    }
+}
+```
+
+- [ ] **Step 2: 创建 AdminRecipeController**
+
+```java
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.Recipe;
+import com.etotem.cfc.service.RecipeService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@Tag(name = "管理端-食谱管理")
+@RestController
+@RequestMapping("/api/admin/recipes")
+public class AdminRecipeController {
+
+    @Resource
+    private RecipeService recipeService;
+
+    @Operation(summary = "食谱列表")
+    @PostMapping("/list")
+    public Result<List<Recipe>> list(@RequestBody Map<String, Object> params) {
+        String mealType = (String) params.get("mealType");
+        return Result.success(recipeService.listByMealType(mealType));
+    }
+
+    @Operation(summary = "新增食谱")
+    @PostMapping("/create")
+    public Result<String> create(@RequestBody Recipe recipe) {
+        recipeService.create(recipe);
+        return Result.success("ok");
+    }
+
+    @Operation(summary = "更新食谱")
+    @PostMapping("/update")
+    public Result<String> update(@RequestBody Recipe recipe) {
+        recipeService.update(recipe);
+        return Result.success("ok");
+    }
+
+    @Operation(summary = "删除食谱")
+    @PostMapping("/delete")
+    public Result<String> delete(@RequestBody Map<String, Object> params) {
+        Long id = params.get("id") != null ? Long.valueOf(params.get("id").toString()) : null;
+        if (id == null) return Result.error("id不能为空");
+        recipeService.delete(id);
+        return Result.success("ok");
+    }
+}
+```
+
+- [ ] **Step 3: 创建 AdminSeasonalController**
+
+```java
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.SeasonalFood;
+import com.etotem.cfc.service.SeasonalFoodService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@Tag(name = "管理端-应季食材配置")
+@RestController
+@RequestMapping("/api/admin/seasonal")
+public class AdminSeasonalController {
+
+    @Resource
+    private SeasonalFoodService seasonalFoodService;
+
+    @Operation(summary = "应季列表")
+    @PostMapping("/list")
+    public Result<List<SeasonalFood>> list(@RequestBody Map<String, Object> params) {
+        Integer month = params.get("month") != null
+                ? Integer.valueOf(params.get("month").toString()) : null;
+        if (month != null) {
+            return Result.success(seasonalFoodService.listByMonth(month));
+        }
+        // 默认返回当前月份
+        return Result.success(seasonalFoodService.listByMonth(java.time.LocalDate.now().getMonthValue()));
+    }
+
+    @Operation(summary = "新增应季映射")
+    @PostMapping("/create")
+    public Result<String> create(@RequestBody SeasonalFood seasonalFood) {
+        seasonalFoodService.create(seasonalFood);
+        return Result.success("ok");
+    }
+
+    @Operation(summary = "删除应季映射")
+    @PostMapping("/delete")
+    public Result<String> delete(@RequestBody Map<String, Object> params) {
+        Long id = params.get("id") != null ? Long.valueOf(params.get("id").toString()) : null;
+        if (id == null) return Result.error("id不能为空");
+        seasonalFoodService.delete(id);
+        return Result.success("ok");
+    }
+}
+```
+
+- [ ] **Step 4: 验证编译**
+
+Run: `cd cfc-backend && mvn clean compile -q`
+Expected: BUILD SUCCESS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminFoodController.java \
+       cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminRecipeController.java \
+       cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminSeasonalController.java
+git commit -m "feat(admin): add admin CRUD controllers for foods/recipes/seasonal"
+```
+
+---
+
+### Task 10: 前端 — 小程序食谱推荐页面
+
+**Files:**
+- Create: `cfc-frontend/pages/meal/recommend.vue`
+- Create: `cfc-frontend/pages/meal/recipe-detail.vue`
+- Create: `cfc-frontend/pages/meal/meal-log.vue`
+- Create: `cfc-frontend/pages/meal/preference.vue`
+- Modify: `cfc-frontend/pages.json` (注册新页面)
+- Modify: `cfc-frontend/utils/api.js` (新增 API 封装)
+
+- [ ] **Step 1: 在 pages.json 注册页面**
+
+定位到 `cfc-frontend/pages.json`,在 `pages` 数组中追加:
+
+```json
+    ,{
+      "path": "pages/meal/recommend",
+      "style": {
+        "navigationBarTitleText": "食谱推荐"
+      }
+    },{
+      "path": "pages/meal/recipe-detail",
+      "style": {
+        "navigationBarTitleText": "食谱详情"
+      }
+    },{
+      "path": "pages/meal/meal-log",
+      "style": {
+        "navigationBarTitleText": "饮食记录"
+      }
+    },{
+      "path": "pages/meal/preference",
+      "style": {
+        "navigationBarTitleText": "饮食偏好设置"
+      }
+    }
+```
+
+- [ ] **Step 2: 在 api.js 中新增 API 封装**
+
+```javascript
+// 食谱推荐
+export const getMealRecommend = (data) => service.post('/api/meal/recommend', data)
+export const replaceFood = (data) => service.post('/api/meal/replace', data)
+export const createMealLog = (data) => service.post('/api/meal/log', data)
+export const getMealLogs = (data) => service.post('/api/meal/logs', data)
+export const getNutritionSummary = (data) => service.post('/api/meal/nutrition-summary', data)
+```
+
+- [ ] **Step 3: 创建 recommend.vue**
+
+```vue
+<template>
+  <view class="container">
+    <view class="header">
+      <text class="title">本周食谱推荐</text>
+      <button class="refresh-btn" @click="loadRecommend">刷新推荐</button>
+    </view>
+
+    <!-- 营养概览 -->
+    <view class="nutrition-card" v-if="nutritionSummary">
+      <text class="card-title">近7日营养摄入</text>
+      <view class="nutrition-grid">
+        <view class="nut-item">
+          <text class="nut-value">{{ nutritionSummary.avgCalories || '--' }}</text>
+          <text class="nut-label">日均热量(kcal)</text>
+        </view>
+        <view class="nut-item">
+          <text class="nut-value">{{ nutritionSummary.avgProtein || '--' }}</text>
+          <text class="nut-label">蛋白质(g)</text>
+        </view>
+        <view class="nut-item">
+          <text class="nut-value">{{ nutritionSummary.avgFiber || '--' }}</text>
+          <text class="nut-label">膳食纤维(g)</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- AI 推荐结果 -->
+    <view class="recommend-content" v-if="answer">
+      <rich-text :nodes="answer"></rich-text>
+    </view>
+
+    <!-- 可替换食材 -->
+    <view class="replace-section" v-if="replaceOptions.length > 0">
+      <text class="section-title">可替换食材</text>
+      <scroll-view scroll-x class="replace-scroll">
+        <view class="replace-item" v-for="food in replaceOptions" :key="food.id"
+              @click="doReplace(food.id)">
+          <text class="food-name">{{ food.name }}</text>
+          <text class="food-price" v-if="food.priceLevel">价格等级{{ food.priceLevel }}</text>
+        </view>
+      </scroll-view>
+    </view>
+
+    <!-- 营养建议 -->
+    <view class="tip-card" v-if="tipText">
+      <text class="tip-icon">💡</text>
+      <text class="tip-text">{{ tipText }}</text>
+    </view>
+
+    <view class="loading" v-if="loading">
+      <text>AI 正在为你生成食谱...</text>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getMealRecommend, replaceFood } from '@/utils/api'
+
+export default {
+  data() {
+    return {
+      loading: false,
+      answer: '',
+      nutritionSummary: null,
+      replaceOptions: [],
+      tipText: '',
+      conversationId: ''
+    }
+  },
+  onLoad() {
+    this.loadRecommend()
+  },
+  methods: {
+    async loadRecommend() {
+      this.loading = true
+      try {
+        const res = await getMealRecommend({
+          conversationId: this.conversationId
+        })
+        if (res.code === 200) {
+          this.answer = res.data.answer
+          this.nutritionSummary = res.data.nutritionSummary
+          this.replaceOptions = res.data.replaceOptions || []
+          this.conversationId = res.data.conversationId || ''
+          // 解析 tip(从回答中提取最后一段)
+          const parts = res.data.answer.split('\n')
+          this.tipText = parts[parts.length - 1] || ''
+        }
+      } catch (e) {
+        uni.showToast({ title: '推荐失败', icon: 'none' })
+      } finally {
+        this.loading = false
+      }
+    },
+    async doReplace(foodId) {
+      try {
+        const res = await replaceFood({ foodId })
+        if (res.code === 200 && res.data) {
+          uni.showToast({ title: `已替换为${res.data.name}`, icon: 'success' })
+          // 刷新推荐
+          this.loadRecommend()
+        }
+      } catch (e) {
+        uni.showToast({ title: '替换失败', icon: 'none' })
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container { padding: 20rpx; background: #FFF7ED; min-height: 100vh; }
+.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20rpx; }
+.title { font-size: 36rpx; font-weight: bold; color: #333; }
+.refresh-btn { padding: 10rpx 30rpx; background: #F97316; color: white; border-radius: 30rpx; font-size: 28rpx; }
+.nutrition-card { background: white; border-radius: 16rpx; padding: 24rpx; margin-bottom: 20rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06); }
+.card-title { font-size: 28rpx; font-weight: bold; color: #333; margin-bottom: 16rpx; display: block; }
+.nutrition-grid { display: flex; justify-content: space-around; }
+.nut-item { text-align: center; }
+.nut-value { font-size: 36rpx; font-weight: bold; color: #F97316; display: block; }
+.nut-label { font-size: 24rpx; color: #999; margin-top: 8rpx; display: block; }
+.recommend-content { background: white; border-radius: 16rpx; padding: 24rpx; margin-bottom: 20rpx; }
+.replace-section { background: white; border-radius: 16rpx; padding: 24rpx; margin-bottom: 20rpx; }
+.section-title { font-size: 28rpx; font-weight: bold; margin-bottom: 16rpx; display: block; }
+.replace-scroll { white-space: nowrap; }
+.replace-item { display: inline-flex; flex-direction: column; align-items: center; padding: 16rpx 24rpx; margin-right: 16rpx; background: #FFF7ED; border-radius: 12rpx; min-width: 140rpx; }
+.food-name { font-size: 28rpx; color: #333; }
+.food-price { font-size: 22rpx; color: #F97316; margin-top: 8rpx; }
+.tip-card { display: flex; align-items: center; background: #E8F5E9; border-radius: 16rpx; padding: 20rpx; margin-bottom: 20rpx; }
+.tip-icon { font-size: 36rpx; margin-right: 16rpx; }
+.tip-text { font-size: 26rpx; color: #2E7D32; flex: 1; }
+.loading { text-align: center; padding: 60rpx 0; color: #999; }
+</style>
+```
+
+- [ ] **Step 4: 创建 recipe-detail.vue**
+
+```vue
+<template>
+  <view class="container">
+    <view class="recipe-header">
+      <text class="recipe-name">{{ recipe.name }}</text>
+      <text class="recipe-meta">{{ recipe.mealType }} · {{ recipe.cookTime }}分钟 · {{ recipe.difficulty }}</text>
+    </view>
+    <view class="section" v-if="recipe.description">
+      <text class="section-title">简介</text>
+      <text class="section-content">{{ recipe.description }}</text>
+    </view>
+    <view class="section">
+      <text class="section-title">食材清单</text>
+      <view class="ingredient-item" v-for="(item, i) in ingredients" :key="i">
+        <text class="ingr-name">{{ item.name }}</text>
+        <text class="ingr-amount">{{ item.amount }}{{ item.unit }}</text>
+      </view>
+    </view>
+    <view class="section" v-if="steps.length > 0">
+      <text class="section-title">烹饪步骤</text>
+      <view class="step-item" v-for="(step, i) in steps" :key="i">
+        <text class="step-num">{{ i + 1 }}</text>
+        <text class="step-text">{{ step }}</text>
+      </view>
+    </view>
+    <view class="section">
+      <text class="section-title">营养分析</text>
+      <view class="nutrition-grid">
+        <view class="nut-item">
+          <text class="nut-value">{{ recipe.totalCalories || '--' }}</text>
+          <text class="nut-label">热量(kcal)</text>
+        </view>
+        <view class="nut-item">
+          <text class="nut-value">{{ recipe.totalProtein || '--' }}</text>
+          <text class="nut-label">蛋白质(g)</text>
+        </view>
+        <view class="nut-item">
+          <text class="nut-value">{{ recipe.totalFiber || '--' }}</text>
+          <text class="nut-label">纤维(g)</text>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+export default {
+  data() {
+    return {
+      recipe: {},
+      ingredients: [],
+      steps: []
+    }
+  },
+  onLoad(options) {
+    // 从路由参数或本地存储获取食谱详情
+    if (options.data) {
+      try {
+        const data = JSON.parse(decodeURIComponent(options.data))
+        this.recipe = data
+        if (data.ingredients) {
+          this.ingredients = typeof data.ingredients === 'string'
+            ? JSON.parse(data.ingredients) : data.ingredients
+        }
+        if (data.steps) {
+          this.steps = typeof data.steps === 'string'
+            ? JSON.parse(data.steps) : data.steps
+        }
+      } catch (e) {}
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container { padding: 20rpx; background: #FFF7ED; min-height: 100vh; }
+.recipe-header { margin-bottom: 24rpx; }
+.recipe-name { font-size: 40rpx; font-weight: bold; color: #333; display: block; }
+.recipe-meta { font-size: 26rpx; color: #999; margin-top: 12rpx; display: block; }
+.section { background: white; border-radius: 16rpx; padding: 24rpx; margin-bottom: 20rpx; }
+.section-title { font-size: 28rpx; font-weight: bold; color: #333; margin-bottom: 16rpx; display: block; }
+.section-content { font-size: 26rpx; color: #666; line-height: 1.6; }
+.ingredient-item { display: flex; justify-content: space-between; padding: 12rpx 0; border-bottom: 1rpx solid #f5f5f5; }
+.ingr-name { font-size: 28rpx; color: #333; }
+.ingr-amount { font-size: 26rpx; color: #999; }
+.step-item { display: flex; margin-bottom: 16rpx; }
+.step-num { width: 40rpx; height: 40rpx; background: #F97316; color: white; border-radius: 50%; text-align: center; line-height: 40rpx; font-size: 24rpx; margin-right: 16rpx; flex-shrink: 0; }
+.step-text { font-size: 26rpx; color: #666; line-height: 1.6; flex: 1; }
+.nutrition-grid { display: flex; justify-content: space-around; }
+.nut-item { text-align: center; }
+.nut-value { font-size: 32rpx; font-weight: bold; color: #F97316; display: block; }
+.nut-label { font-size: 22rpx; color: #999; margin-top: 8rpx; display: block; }
+</style>
+```
+
+- [ ] **Step 5: 创建 meal-log.vue**
+
+```vue
+<template>
+  <view class="container">
+    <view class="log-form">
+      <picker :range="mealTypes" @change="onMealTypeChange">
+        <view class="form-item">
+          <text class="form-label">餐次</text>
+          <text class="form-value">{{ form.mealType || '请选择' }}</text>
+        </view>
+      </picker>
+      <picker mode="date" @change="onDateChange">
+        <view class="form-item">
+          <text class="form-label">日期</text>
+          <text class="form-value">{{ form.mealDate }}</text>
+        </view>
+      </picker>
+      <view class="form-item">
+        <text class="form-label">食物</text>
+        <input class="form-input" v-model="foodText" placeholder="输入食物名称,逗号分隔" />
+      </view>
+      <view class="form-item">
+        <text class="form-label">备注</text>
+        <input class="form-input" v-model="form.note" placeholder="可选备注" />
+      </view>
+      <button class="submit-btn" @click="submitLog">保存记录</button>
+    </view>
+
+    <view class="history">
+      <text class="section-title">近期记录</text>
+      <view class="log-item" v-for="log in logs" :key="log.id">
+        <text class="log-meal">{{ log.mealType }} · {{ formatDate(log.mealDate) }}</text>
+        <text class="log-foods">{{ log.foods }}</text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { createMealLog, getMealLogs } from '@/utils/api'
+
+export default {
+  data() {
+    return {
+      mealTypes: ['早餐', '午餐', '晚餐', '加餐'],
+      form: { mealType: '', mealDate: '', note: '' },
+      foodText: '',
+      logs: []
+    }
+  },
+  onLoad() {
+    this.form.mealDate = new Date().toISOString().slice(0, 10)
+    this.loadLogs()
+  },
+  methods: {
+    onMealTypeChange(e) { this.form.mealType = this.mealTypes[e.detail.value] },
+    onDateChange(e) { this.form.mealDate = e.detail.value },
+    formatDate(d) { return d ? d.slice(0, 10) : '' },
+    async loadLogs() {
+      try {
+        const res = await getMealLogs({ days: 7 })
+        if (res.code === 200) this.logs = res.data
+      } catch (e) {}
+    },
+    async submitLog() {
+      if (!this.form.mealType || !this.foodText) {
+        uni.showToast({ title: '请完整填写', icon: 'none' })
+        return
+      }
+      const foods = this.foodText.split(/[,,]/).map(s => s.trim()).filter(Boolean)
+        .map(name => ({ name }))
+      try {
+        await createMealLog({
+          mealType: this.form.mealType,
+          mealDate: this.form.mealDate,
+          foods: JSON.stringify(foods),
+          note: this.form.note
+        })
+        uni.showToast({ title: '记录成功', icon: 'success' })
+        this.foodText = ''
+        this.form.note = ''
+        this.loadLogs()
+      } catch (e) {
+        uni.showToast({ title: '保存失败', icon: 'none' })
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container { padding: 20rpx; background: #FFF7ED; min-height: 100vh; }
+.log-form { background: white; border-radius: 16rpx; padding: 24rpx; margin-bottom: 20rpx; }
+.form-item { display: flex; justify-content: space-between; align-items: center; padding: 20rpx 0; border-bottom: 1rpx solid #f5f5f5; }
+.form-label { font-size: 28rpx; color: #333; }
+.form-value { font-size: 28rpx; color: #999; }
+.form-input { flex: 1; text-align: right; font-size: 28rpx; color: #333; }
+.submit-btn { margin-top: 30rpx; background: #F97316; color: white; border-radius: 40rpx; }
+.history { background: white; border-radius: 16rpx; padding: 24rpx; }
+.section-title { font-size: 28rpx; font-weight: bold; margin-bottom: 16rpx; display: block; }
+.log-item { padding: 16rpx 0; border-bottom: 1rpx solid #f5f5f5; }
+.log-meal { font-size: 26rpx; color: #333; display: block; }
+.log-foods { font-size: 24rpx; color: #999; margin-top: 8rpx; display: block; }
+</style>
+```
+
+- [ ] **Step 6: 创建 preference.vue**
+
+```vue
+<template>
+  <view class="container">
+    <view class="form-card">
+      <text class="card-title">饮食偏好设置</text>
+
+      <view class="form-item">
+        <text class="label">月食品预算(元)</text>
+        <input class="input" type="number" v-model="form.budgetMonthly" placeholder="输入预算" />
+      </view>
+
+      <view class="form-item">
+        <text class="label">口味偏好</text>
+        <view class="tag-group">
+          <text v-for="t in tasteOptions" :key="t"
+                :class="['tag', form.taste === t ? 'active' : '']"
+                @click="form.taste = t">{{ t }}</text>
+        </view>
+      </view>
+
+      <view class="form-item">
+        <text class="label">偏好菜系</text>
+        <picker :range="cuisineOptions" @change="e => form.cuisine = cuisineOptions[e.detail.value]">
+          <text class="input">{{ form.cuisine || '请选择' }}</text>
+        </picker>
+      </view>
+
+      <view class="form-item">
+        <text class="label">烹饪能力</text>
+        <picker :range="cookOptions" @change="e => form.cookingAbility = cookOptions[e.detail.value]">
+          <text class="input">{{ form.cookingAbility || '请选择' }}</text>
+        </picker>
+      </view>
+
+      <button class="save-btn" @click="saveProfile">保存设置</button>
+    </view>
+  </view>
+</template>
+
+<script>
+export default {
+  data() {
+    return {
+      tasteOptions: ['清淡', '辛辣', '甜', '咸', '酸', '无偏好'],
+      cuisineOptions: ['中式', '西式', '日式', '韩式', '东南亚', '无偏好'],
+      cookOptions: ['简单(煮/蒸)', '中等(炒/煎)', '高级(烘焙/炖)'],
+      form: { budgetMonthly: 2000, taste: '清淡', cuisine: '', cookingAbility: '简单(煮/蒸)' }
+    }
+  },
+  methods: {
+    async saveProfile() {
+      // TODO: 调用用户偏好保存 API(需实现 UserNutritionProfileController)
+      uni.showToast({ title: '保存成功', icon: 'success' })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container { padding: 20rpx; background: #FFF7ED; min-height: 100vh; }
+.form-card { background: white; border-radius: 16rpx; padding: 24rpx; }
+.card-title { font-size: 32rpx; font-weight: bold; color: #333; margin-bottom: 24rpx; display: block; }
+.form-item { margin-bottom: 24rpx; }
+.label { font-size: 28rpx; color: #333; margin-bottom: 12rpx; display: block; }
+.input { font-size: 28rpx; color: #333; border: 1rpx solid #eee; border-radius: 12rpx; padding: 16rpx; }
+.tag-group { display: flex; flex-wrap: wrap; gap: 12rpx; }
+.tag { padding: 12rpx 24rpx; border-radius: 30rpx; font-size: 26rpx; background: #f5f5f5; color: #666; }
+.tag.active { background: #F97316; color: white; }
+.save-btn { margin-top: 40rpx; background: #F97316; color: white; border-radius: 40rpx; }
+</style>
+```
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add cfc-frontend/pages.json \
+       cfc-frontend/utils/api.js \
+       cfc-frontend/pages/meal/recommend.vue \
+       cfc-frontend/pages/meal/recipe-detail.vue \
+       cfc-frontend/pages/meal/meal-log.vue \
+       cfc-frontend/pages/meal/preference.vue
+git commit -m "feat(mp): add meal recommendation and logging pages"
+```
+
+---
+
+### Task 11: Web 管理端 — 食材/食谱/应季管理
+
+**Files:**
+- Create: `cfc-web/src/views/admin/foods/index.vue`
+- Create: `cfc-web/src/views/admin/recipes/index.vue`
+- Create: `cfc-web/src/views/admin/seasonal/index.vue`
+- Modify: `cfc-web/src/router/index.js` (注册新路由)
+- Modify: `cfc-web/src/utils/api.js` (新增 API)
+
+- [ ] **Step 1: 注册管理端路由**
+
+在 `cfc-web/src/router/index.js` 中,`adminRoutes` 数组内追加:
+
+```javascript
+  {
+    path: '/admin/foods',
+    component: () => import('@/views/admin/foods/index'),
+    meta: { title: '食材管理', roles: ['admin'] }
+  },
+  {
+    path: '/admin/recipes',
+    component: () => import('@/views/admin/recipes/index'),
+    meta: { title: '食谱管理', roles: ['admin'] }
+  },
+  {
+    path: '/admin/seasonal',
+    component: () => import('@/views/admin/seasonal/index'),
+    meta: { title: '应季食材配置', roles: ['admin'] }
+  },
+```
+
+- [ ] **Step 2: 新增 API 封装**
+
+在 `cfc-web/src/utils/api.js` 中追加:
+
+```javascript
+// 食材管理 (FoodService REST API — 需实现 AdminFoodController)
+export const getFoodList = (params) => service.post('/api/admin/foods/list', params)
+export const createFood = (data) => service.post('/api/admin/foods/create', data)
+export const updateFood = (data) => service.post('/api/admin/foods/update', data)
+export const deleteFood = (id) => service.post('/api/admin/foods/delete', { id })
+
+// 食谱管理
+export const getRecipeList = (params) => service.post('/api/admin/recipes/list', params)
+export const createRecipe = (data) => service.post('/api/admin/recipes/create', data)
+export const updateRecipe = (data) => service.post('/api/admin/recipes/update', data)
+export const deleteRecipe = (id) => service.post('/api/admin/recipes/delete', { id })
+
+// 应季食材
+export const getSeasonalList = (params) => service.post('/api/admin/seasonal/list', params)
+export const createSeasonal = (data) => service.post('/api/admin/seasonal/create', data)
+export const deleteSeasonal = (id) => service.post('/api/admin/seasonal/delete', { id })
+```
+
+- [ ] **Step 3~5: 创建 CRUD 页面**
+
+每个管理页面为标准的 Element UI 表格 CRUD(以 foods/index.vue 为例):
+
+```vue
+<template>
+  <div class="app-container">
+    <el-card>
+      <div slot="header">
+        <span>食材管理</span>
+        <el-button type="primary" size="small" style="float:right" @click="showDialog = true">新增食材</el-button>
+      </div>
+      <el-table :data="list" border stripe>
+        <el-table-column prop="name" label="名称" width="150" />
+        <el-table-column prop="category" label="分类" width="100" />
+        <el-table-column prop="calories" label="热量(kcal)" width="100" />
+        <el-table-column prop="protein" label="蛋白质(g)" width="100" />
+        <el-table-column prop="fiber" label="纤维(g)" width="100" />
+        <el-table-column prop="priceLevel" label="价格等级" width="90" />
+        <el-table-column prop="nutritionTags" label="营养标签" min-width="200">
+          <template slot-scope="{ row }">
+            <el-tag v-for="tag in parseTags(row.nutritionTags)" :key="tag" size="mini" style="margin-right:4px">{{ tag }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="150" fixed="right">
+          <template slot-scope="{ row }">
+            <el-button size="mini" @click="editRow(row)">编辑</el-button>
+            <el-button size="mini" type="danger" @click="deleteRow(row.id)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-card>
+
+    <!-- 新增/编辑对话框 -->
+    <el-dialog :visible.sync="showDialog" :title="isEdit ? '编辑食材' : '新增食材'" width="600px">
+      <el-form :model="form" label-width="100px">
+        <el-form-item label="名称"><el-input v-model="form.name" /></el-form-item>
+        <el-form-item label="分类">
+          <el-select v-model="form.category">
+            <el-option v-for="c in categories" :key="c" :label="c" :value="c" />
+          </el-select>
+        </el-form-item>
+        <el-row :gutter="20">
+          <el-col :span="8"><el-form-item label="热量"><el-input v-model="form.calories" /></el-form-item></el-col>
+          <el-col :span="8"><el-form-item label="蛋白质"><el-input v-model="form.protein" /></el-form-item></el-col>
+          <el-col :span="8"><el-form-item label="纤维"><el-input v-model="form.fiber" /></el-form-item></el-col>
+        </el-row>
+        <el-form-item label="价格等级(1-5)">
+          <el-rate v-model="form.priceLevel" :max="5" show-text />
+        </el-form-item>
+        <el-form-item label="营养标签">
+          <el-select v-model="form.nutritionTags" multiple filterable allow-create default-first-option>
+            <el-option v-for="t in tagOptions" :key="t" :label="t" :value="t" />
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <span slot="footer">
+        <el-button @click="showDialog = false">取消</el-button>
+        <el-button type="primary" @click="saveRow">保存</el-button>
+      </span>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { getFoodList, createFood, updateFood, deleteFood } from '@/api'
+
+export default {
+  data() {
+    return {
+      list: [], showDialog: false, isEdit: false,
+      categories: ['蔬菜', '水果', '肉禽', '水产', '蛋奶', '豆类', '谷物', '调味', '其他'],
+      tagOptions: ['高纤维', '高蛋白', '维生素B12', '维生素C', '益生元', '低脂', '低糖', '高铁', '高钙'],
+      form: { name: '', category: '', calories: null, protein: null, fiber: null, priceLevel: 3, nutritionTags: [] }
+    }
+  },
+  mounted() { this.loadData() },
+  methods: {
+    parseTags(t) { return t ? (Array.isArray(t) ? t : JSON.parse(t)) : [] },
+    async loadData() {
+      const res = await getFoodList({})
+      if (res.code === 200) this.list = res.data
+    },
+    editRow(row) { this.form = { ...row }; this.isEdit = true; this.showDialog = true },
+    async saveRow() {
+      const data = { ...this.form }
+      data.nutritionTags = Array.isArray(data.nutritionTags) ? JSON.stringify(data.nutritionTags) : data.nutritionTags
+      if (this.isEdit) await updateFood(data); else await createFood(data)
+      this.$message.success('保存成功')
+      this.showDialog = false; this.isEdit = false
+      this.loadData()
+    },
+    async deleteRow(id) {
+      await this.$confirm('确认删除?')
+      await deleteFood(id)
+      this.$message.success('删除成功')
+      this.loadData()
+    }
+  }
+}
+</script>
+```
+
+食谱管理页面和应季食材配置页面类似(CRUD 模式),不再重复完整代码。可参考 foods/index.vue 的结构实现。
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add cfc-web/src/router/index.js \
+       cfc-web/src/utils/api.js \
+       cfc-web/src/views/admin/foods/index.vue \
+       cfc-web/src/views/admin/recipes/index.vue \
+       cfc-web/src/views/admin/seasonal/index.vue
+git commit -m "feat(web): add admin food/recipe/seasonal management pages"
+```
+
+---
+
+### Task 12: Dify System Prompt 扩展
+
+**Files:**
+- Modify: Dify 应用 "CFC 精准营养助手" 的 System Prompt(需手动登录 http://dify.bianwooyou.cn 操作)
+
+- [ ] **Step 1: 在 Dify System Prompt 末尾追加食谱推荐变量**
+
+在现有 Prompt 末尾追加:
+
+```
+## 6. 食谱推荐(新增)
+
+当用户请求食谱推荐时,你将接收到以下输入变量:
+
+### {{user_profile}}
+JSON: { taste, cuisine, budget, dietaryRestrictions }
+- budget: 月食品预算(元)
+- taste: 口味偏好(清淡/辛辣等)
+- dietaryRestrictions: 饮食限制(过敏原等)
+
+### {{health_summary}}
+JSON: { overallScore, gutType, abnormalIndicators: [...] }
+- gutType: 肠型(拟杆菌型/普氏菌型/混合型)
+- abnormalIndicators: 异常指标列表 [{ name, value, status, refRange }]
+
+### {{recent_intake}}
+JSON: { avgCalories, avgProtein, avgFiber, totalMeals }
+- 近7天营养摄入均值
+- 如果 totalMeals 为 0,说明用户无打卡记录,忽略此输入
+
+### {{candidate_foods}}
+Array: [{ name, category, priceLevel, nutritionTags }]
+- 已按用户预算和当月应季筛选后的候选食材
+- 你的输出必须基于这些食材,不能推荐候选列表外的食材
+
+### {{candidate_recipes}}
+Array: [{ name, mealType, cookTime, nutritionTags }]
+- 可选预设食谱,可选用或参考其结构设计新食谱
+
+### {{meal_type}}
+String: "breakfast" / "lunch" / "dinner" / "snack" / null(全部推荐)
+
+输出要求:
+返回结构化 JSON(非 Markdown)。格式:
+{
+  "dailyMeals": [
+    {
+      "day": "周一",
+      "meals": [
+        {
+          "type": "早餐",
+          "recipeName": "燕麦蓝莓粥+水煮蛋",
+          "reason": "高纤维启动肠道,适合拟杆菌型",
+          "ingredients": [{"name":"燕麦","amount":"50","unit":"g"}],
+          "tips": "燕麦提前浸泡口感更好"
+        }
+      ]
+    }
+  ],
+  "forbiddenHint": "需避免的食物说明",
+  "nutritionTip": "营养建议",
+  "tags": ["拟杆菌型","高纤维","低脂"]
+}
+```
+
+- [ ] **Step 2: 测试推荐接口**
+
+Run: 启动后端后,测试推荐端点
+
+```bash
+curl -s -X POST http://localhost:8080/api/meal/recommend \
+  -H "Authorization: Bearer $TOKEN" \
+  -H "Content-Type: application/json" \
+  -d '{}'
+```
+
+Expected: 返回包含 answer (JSON 格式食谱方案)、nutritionSummary、replaceOptions
+
+- [ ] **Step 3: Commit**
+
+```bash
+# Dify Prompt 变更无法通过 git 追踪
+# 记录变更到设计文档
+echo "Dify System Prompt 已更新: 追加食材推荐相关 inputs 变量" >> docs/superpowers/specs/2026-06-22-食谱推荐系统设计方案.md
+git add docs/superpowers/specs/2026-06-22-食谱推荐系统设计方案.md
+git commit -m "docs: update spec with Dify prompt changes for meal recommendation"
+```
+
+---
+
+## Self-Review Checklist
+
+- [ ] **Spec coverage** — 每个设计部分对应的任务:
+  - 数据模型 (3.1-3.6) → Task 1 (DDL) + Task 2 (Entity) + Task 3 (Mapper)
+  - 候选集过滤算法 (4.2) → Task 7 (MealRecommendService.filterCandidates)
+  - Dify AI 编排 (4.3) → Task 7 (buildDifyInputs) + Task 11 (Dify Prompt)
+  - 一回换一菜 (4.4) → Task 7 (findReplaceCandidate) + Task 8 (replace API)
+  - 饮食日志 → Task 5 (MealLogService) + Task 8 (log/logs/nutrition-summary API)
+  - Web 管理端 → Task 10
+  - 小程序页面 → Task 9
+  - 外部 API 集成 → 设计文档指定为 P3,不在当前计划覆盖
+- [ ] **Placeholder scan** — 无 TBD/TODO/占位符(aggregateContext 的 TODO 标记了"后续替换")
+- [ ] **Type consistency** — DTO/Entity/Service/Controller 之间的类型签名一致
+- [ ] **Missing tasks** — 无

+ 426 - 0
docs/superpowers/specs/2026-06-22-食谱推荐系统设计方案.md

@@ -0,0 +1,426 @@
+# 食谱推荐系统 — 设计方案
+
+**版本**: 1.0
+**日期**: 2026-06-22
+**状态**: 设计完成,待实现
+
+---
+
+## 1. 概述
+
+### 1.1 目标
+
+基于用户的个人健康情况、饮食喜好、家庭健康情况及喜好、财力预算、应季食品和近期营养摄入情况,构建食谱推荐系统。采用混合架构——结构化食品/食谱数据库做候选筛选,Dify AI 做最终个性化编排。
+
+### 1.2 角色覆盖
+
+| 角色 | 使用场景 | 数据范围 |
+|------|---------|---------|
+| 家长 (parent) | 查看自己/孩子的食谱推荐、记录家庭饮食、设定偏好的预算 | 自己 + 家庭内孩子 |
+| 孩子 (child) | 查看自己的食谱、记录每日饮食 | 自己 |
+| 规划师 (teacher) | 查看所服务家庭的饮食情况,给出改善建议 | 已绑定家庭的孩子 |
+| 管理员 (admin) | 管理食材库、食谱库、应季食材配置,导入外部数据 | 全平台 |
+
+### 1.3 核心输入维度
+
+| 维度 | 数据来源 | 现有/新增 |
+|------|---------|----------|
+| 个人健康情况 | `HealthReport` + `HealthIndicator` + `HealthGutFlora` | 已有 |
+| 个人喜好 | `user_nutrition_profile` (口味/过敏/偏好菜系) | 已设计,待实现 |
+| 家庭健康及喜好 | `user_nutrition_profile.family_members` + 家庭口味聚合 | 新增 |
+| 财力预算 | `user_nutrition_profile.budget_monthly` (用户手动设定) | 新增 |
+| 应季食品 | `seasonal_foods` 表 (运营按月维护) | 新增 |
+| 近期营养摄入 | `meal_logs` 近 7 天饮食记录,计算营养剩余配额 | 新增 |
+
+---
+
+## 2. 系统架构
+
+### 2.1 混合架构(DB 候选过滤 + Dify AI 个性化)
+
+```
+┌─────────────┐     POST /api/meal/recommend      ┌────────────────────────┐
+│  前端        │  ──────────────────────────────→  │  MealRecommendController │
+│  (小程序/    │                                   │  - 健康报告读取          │
+│   Web管理端)  │  ←────────────────────────────── │  - 偏好/预算/日志聚合    │
+└─────────────┘     食谱方案 + 推荐理由             │  - DB候选过滤           │
+                                                    │  - Dify AI编排          │
+                                                    └───────┬────────────────┘
+                                                            │ Dify API
+                                                            ▼
+                                                    ┌────────────────────────┐
+                                                    │  Dify 精准营养助手      │
+                                                    │  (扩展 System Prompt)   │
+                                                    │  ┌──────┐ ┌─────────┐  │
+                                                    │  │ LLM  │ │ 精准营养  │  │
+                                                    │  │ 推理  │ │ 知识库   │  │
+                                                    │  └──────┘ └─────────┘  │
+                                                    └────────────────────────┘
+```
+
+### 2.2 推荐数据流
+
+```
+POST /api/meal/recommend { userId, mealType? }
+
+Step 1: 数据聚合 (后端)
+  ├─ HealthReportService.getLatestReport(userId)
+  ├─ HealthReportService.getReportIndicators(reportId)
+  ├─ UserNutritionProfileService.getProfile(userId)
+  ├─ MealLogService.getRecentIntake(userId, days=7)
+  └─ SeasonalFoodService.getMonthlyFoods(month=current)
+
+       ↓
+
+Step 2: DB 候选集过滤 (后端)
+  ├─ 应季过滤: seasonal_foods WHERE month = CURRENT
+  ├─ 预算过滤: foods.price_level ≤ budget_level
+  ├─ 过敏排除: foods.allergens NOT IN user.allergies
+  ├─ 健康匹配: foods.nutrition_tags 匹配报告缺口
+  └─ 口味匹配: recipes.suitable_for 匹配用户
+
+       ↓
+
+Step 3: AI 个性化 (Dify)
+  ├─ 输入: user_profile + health_summary + family_preferences
+  │        + budget_info + recent_intake + candidate_foods + candidate_recipes
+  └─ 输出: 结构化周食谱方案 (JSON)
+
+       ↓
+
+Step 4: 结果缓存 + 返回
+  ├─ 推荐缓存到 Redis (key=meal:recommend:{userId})
+  └─ 返回: { weekPlan, nutritionSummary, replaceOptions }
+```
+
+---
+
+## 3. 数据模型
+
+### 3.1 `foods` — 食材库
+
+```sql
+CREATE TABLE foods (
+    id              BIGINT AUTO_INCREMENT PRIMARY KEY,
+    name            VARCHAR(100) NOT NULL COMMENT '食材名称',
+    category        VARCHAR(50) COMMENT '分类: 蔬菜/水果/肉禽/水产/蛋奶/豆类/谷物/调味/其他',
+    unit            VARCHAR(20) DEFAULT 'g' COMMENT '计量单位',
+    -- 营养素 (每100g)
+    calories        DECIMAL(8,2) COMMENT '热量(kcal)',
+    protein         DECIMAL(8,2) COMMENT '蛋白质(g)',
+    fat             DECIMAL(8,2) COMMENT '脂肪(g)',
+    carbs           DECIMAL(8,2) COMMENT '碳水化合物(g)',
+    fiber           DECIMAL(8,2) COMMENT '膳食纤维(g)',
+    -- 价格指数 (运营维护: 1-5, 1=廉价 5=昂贵)
+    price_level     TINYINT COMMENT '价格等级 1-5',
+    -- 营养标签 (JSON, 如 ["高纤维","高蛋白","维生素B12","益生元"])
+    nutrition_tags  VARCHAR(500) COMMENT '营养标签',
+    -- 适宜人群 (JSON, 如 ["儿童","成人","老人"])
+    suitable_for    VARCHAR(200) COMMENT '适宜人群',
+    -- 常见过敏原 (JSON)
+    allergens       VARCHAR(200) COMMENT '过敏原',
+    -- 外部API来源
+    external_source VARCHAR(100) COMMENT '数据来源: manual/spoonacular/edamam',
+    external_id     VARCHAR(100) COMMENT '外部API中的ID',
+    cover_image     VARCHAR(500) COMMENT '食材图片',
+    status          VARCHAR(20) DEFAULT 'active',
+    sort_order      INT DEFAULT 0,
+    created_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at      DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    INDEX idx_category (category),
+    INDEX idx_price (price_level)
+);
+```
+
+### 3.2 `recipes` — 预设食谱
+
+```sql
+CREATE TABLE recipes (
+    id              BIGINT AUTO_INCREMENT PRIMARY KEY,
+    name            VARCHAR(200) NOT NULL COMMENT '食谱名称',
+    meal_type       VARCHAR(20) COMMENT '早餐/午餐/晚餐/加餐',
+    description     TEXT COMMENT '简介',
+    ingredients     TEXT NOT NULL COMMENT '食材清单 (JSON: [{foodId, name, amount, unit}])',
+    steps           TEXT COMMENT '烹饪步骤 (JSON数组)',
+    cook_time       INT COMMENT '烹饪时间(分钟)',
+    difficulty      VARCHAR(20) COMMENT '难度: 简单/中等/困难',
+    -- 营养汇总 (自动计算)
+    total_calories  DECIMAL(8,2) COMMENT '总热量(kcal)',
+    total_protein   DECIMAL(8,2) COMMENT '总蛋白质(g)',
+    total_fat       DECIMAL(8,2) COMMENT '总脂肪(g)',
+    total_carbs     DECIMAL(8,2) COMMENT '总碳水(g)',
+    total_fiber     DECIMAL(8,2) COMMENT '总膳食纤维(g)',
+    -- 推荐场景
+    suitable_for    VARCHAR(200) COMMENT '适宜场景: 减脂/增肌/肠道改善/儿童/全家',
+    nutrition_tags  VARCHAR(500) COMMENT '营养标签',
+    external_source VARCHAR(100),
+    external_id     VARCHAR(100),
+    cover_image     VARCHAR(500),
+    status          VARCHAR(20) DEFAULT 'active',
+    created_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at      DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    INDEX idx_meal_type (meal_type),
+    INDEX idx_cook_time (cook_time)
+);
+```
+
+### 3.3 `seasonal_foods` — 应季食材映射
+
+```sql
+CREATE TABLE seasonal_foods (
+    id              BIGINT AUTO_INCREMENT PRIMARY KEY,
+    food_id         BIGINT NOT NULL COMMENT '关联foods.id',
+    month           TINYINT NOT NULL COMMENT '月份 1-12',
+    region          VARCHAR(50) DEFAULT '全国' COMMENT '产地/区域',
+    peak_flag       TINYINT DEFAULT 0 COMMENT '是否当季盛产(1=是)',
+    sort_order      INT DEFAULT 0,
+    created_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_food_month (food_id, month),
+    INDEX idx_month (month)
+);
+```
+
+### 3.4 `meal_logs` — 饮食日志
+
+```sql
+CREATE TABLE meal_logs (
+    id              BIGINT AUTO_INCREMENT PRIMARY KEY,
+    user_id         BIGINT NOT NULL COMMENT '用户ID',
+    child_id        BIGINT COMMENT '孩子ID(记录孩子的饮食)',
+    meal_type       VARCHAR(20) NOT NULL COMMENT '早餐/午餐/晚餐/加餐',
+    meal_date       DATE NOT NULL COMMENT '用餐日期',
+    meal_time       DATETIME COMMENT '具体用餐时间',
+    -- 食物记录 (JSON: [{foodId, name, amount, unit, recipeId?}])
+    foods           TEXT NOT NULL COMMENT '食物清单JSON',
+    -- 营养汇总 (自动计算)
+    total_calories  DECIMAL(8,2) COMMENT '总热量',
+    total_protein   DECIMAL(8,2) COMMENT '总蛋白质',
+    total_fat       DECIMAL(8,2) COMMENT '总脂肪',
+    total_carbs     DECIMAL(8,2) COMMENT '总碳水',
+    total_fiber     DECIMAL(8,2) COMMENT '总膳食纤维',
+    note            TEXT COMMENT '备注',
+    images          VARCHAR(1000) COMMENT '餐食图片(JSON数组)',
+    created_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_user_date (user_id, meal_date),
+    INDEX idx_child_date (child_id, meal_date)
+);
+```
+
+### 3.5 `user_nutrition_profile` 扩展字段
+
+```sql
+ALTER TABLE user_nutrition_profile
+    ADD COLUMN budget_monthly INT COMMENT '月食品预算(元)',
+    ADD COLUMN family_taste_preferences TEXT COMMENT '家庭成员口味偏好JSON',
+    ADD COLUMN cuisine_style VARCHAR(100) COMMENT '偏好菜系',
+    ADD COLUMN meal_count INT DEFAULT 3 COMMENT '每日餐数',
+    ADD COLUMN cooking_ability VARCHAR(20) DEFAULT 'simple' COMMENT '烹饪能力: simple/medium/advanced';
+```
+
+### 3.6 E-R 关系
+
+```
+foods ──1:N── seasonal_foods
+foods ──1:N── recipes.ingredients (通过 JSON 中的 foodId 关联)
+foods ──1:N── meal_logs.foods (通过 JSON 中的 foodId 关联)
+recipes ──1:N── meal_logs.foods (通过 JSON 中的 recipeId 关联)
+user_nutrition_profile ──1:N── meal_logs (user_id)
+```
+
+---
+
+## 4. 推荐引擎算法
+
+### 4.1 Step 1: 数据聚合
+
+`MealRecommendService.aggregate(userId)` 返回 `RecommendationContext`:
+
+```java
+class RecommendationContext {
+    HealthReport latestReport;
+    List<HealthIndicator> abnormalIndicators;
+    UserNutritionProfile profile;        // 含预算/偏好/过敏
+    List<FamilyMember> familyMembers;     // 家庭成员口味
+    NutritionSummary recentIntake;        // 近7天营养统计
+    List<Food> monthlySeasonalFoods;      // 当月应季食材
+}
+```
+
+### 4.2 Step 2: DB 候选集过滤
+
+```sql
+-- 应季 + 预算 + 无过敏 + 健康标签匹配
+SELECT DISTINCT f.* FROM foods f
+JOIN seasonal_foods sf ON f.id = sf.food_id
+WHERE sf.month = :currentMonth
+  AND f.price_level <= :budgetLevel      -- 预算过滤
+  AND f.status = 'active'
+  AND (f.allergens IS NULL OR f.allergens NOT LIKE :userAllergen)  -- 过敏排除
+  AND f.nutrition_tags LIKE ANY(:healthTags)  -- 健康匹配 (LIKE 兜底)
+ORDER BY sf.peak_flag DESC, f.price_level ASC
+LIMIT 30;
+```
+
+**输出限制:** foods ≤ 30 条, recipes ≤ 15 条(由前端 `mealType` 决定是否需要过滤早餐/午餐/晚餐)。
+
+### 4.3 Step 3: Dify AI 个性化编排
+
+**Dify System Prompt 扩展——新增 inputs 变量:**
+
+```
+可用变量:
+- {{user_profile}}: { age, role, healthGoal (from user_nutrition_profile) }
+- {{health_summary}}: { overallScore, gutType, abnormalIndicators: [...], diseaseRisks: [...] }
+- {{family_preferences}}: { members: [{ name, age, taste }], cookingAbility }
+- {{budget_info}}: { monthlyBudget, priceLevelLimit }
+- {{recent_intake}}: { days: 7, avgCalories, avgProtein, avgFiber, deficiencies: [...] }
+- {{candidate_foods}}: [{ name, category, calories, protein, priceLevel, nutritionTags }]
+- {{candidate_recipes}}: [{ name, mealType, calories, cookTime, difficulty, nutritionTags }]
+- {{meal_type}}: "breakfast" | "lunch" | "dinner" | "snack" | null (全部)
+```
+
+**Dify 输出格式(结构化 JSON):**
+
+```json
+{
+  "dailyMeals": [
+    {
+      "day": "周一",
+      "meals": [
+        {
+          "type": "早餐",
+          "recipeName": "燕麦蓝莓粥+水煮蛋",
+          "reason": "高纤维启动肠道,蓝莓抗氧化,适合拟杆菌型",
+          "ingredients": [
+            {"foodId": 101, "name": "燕麦", "amount": "50", "unit": "g"},
+            {"foodId": 204, "name": "蓝莓", "amount": "30", "unit": "g"},
+            {"foodId": 305, "name": "鸡蛋", "amount": "1", "unit": "个"}
+          ],
+          "tips": "燕麦提前浸泡口感更好"
+        }
+      ]
+    }
+  ],
+  "forbiddenHint": "避免油炸食品和加工肉类",
+  "nutritionTip": "本周需重点补充膳食纤维和维生素B12",
+  "tags": ["拟杆菌型", "高纤维", "低脂", "全家适用"]
+}
+```
+
+### 4.4 Step 4: 一回换一菜
+
+**端点:** `POST /api/meal/replace`
+
+**逻辑:** 从当前候选集中取同类食材替换(同 category + 近似 price_level):
+
+```java
+public Food findReplaceCandidate(Long currentFoodId, Long userId) {
+    Food current = foodMapper.selectById(currentFoodId);
+    // 同品类、同级价格、不同食材
+    return foodMapper.selectOne(
+        new LambdaQueryWrapper<Food>()
+            .eq(Food::getCategory, current.getCategory())
+            .eq(Food::getPriceLevel, current.getPriceLevel())
+            .ne(Food::getId, currentFoodId)
+            .last("ORDER BY RAND() LIMIT 1")
+    );
+}
+```
+
+**不再次调用 Dify**,保证 < 100ms 响应。
+
+---
+
+## 5. 外部 API 集成策略
+
+| API | 用途 | 接入方式 | 优先级 |
+|-----|------|---------|--------|
+| **Spoonacular** | 食材图片、营养素、预设食谱 | 运营初始化时批量导入 → 存本地 foods/recipes 表 | P1 |
+| **Edamam** | 食谱搜索、营养分析 | 用户搜索新食谱时按需调用,结果缓存到本地 | P2 |
+| **国内菜谱 API** (豆果/下厨房) | 中文食谱 | 待评估国内 API 可用性 | P3 |
+
+**核心原则:** 外部 API 是**数据填充工具**,非运行时依赖。推荐系统仅依赖本地数据 + Dify AI。
+
+---
+
+## 6. 前端设计
+
+### 6.1 新增小程序页面
+
+| 页面 | 路径 | 功能 |
+|------|------|------|
+| 食谱推荐首页 | `pages/meal/recommend.vue` | 本周食谱日历(每日三餐),推荐理由,一键换菜 |
+| 食谱详情 | `pages/meal/recipe-detail.vue` | 食材清单、烹饪步骤、营养分析 |
+| 饮食记录 | `pages/meal/meal-log.vue` | 选择餐次 → 搜索/选择食物 → 记录保存 |
+| 偏好设置 | `pages/meal/preference.vue` | 预算、口味偏好、过敏原、烹饪能力 |
+
+### 6.2 新增 Web 管理端页面
+
+| 页面 | 功能 |
+|------|------|
+| `views/admin/foods/index.vue` | 食材管理:列表/新增/编辑/批量导入外部数据 |
+| `views/admin/recipes/index.vue` | 食谱管理:预设食谱 CRUD |
+| `views/admin/seasonal/index.vue` | 应季食材配置:月度对应关系维护 |
+
+### 6.3 页面注册
+
+需在 `cfc-frontend/pages.json` 注册新页面,以及在 `cfc-web/src/router/index.js` 注册管理端路由。
+
+---
+
+## 7. 与现有系统的对接点
+
+| 现有模块 | 对接方式 |
+|---------|---------|
+| `HealthReportService` | 复用 `getLatestReport()` + `getReportDetail()` 读取健康数据 |
+| `FamilyContextService` | 扩展 `buildContext()` 增加食谱候选和饮食日志上下文 |
+| `AIService` + `AIChatController` | 复用 Dify 调用逻辑,扩展 nutrition/send 的 inputs |
+| `HealthCheckin` | 饮食日志可作为"身(body)"维度的打卡数据源 |
+| `Product` 表 | 后续可扩展食材直接关联商城商品("购买食材"按钮) |
+| `Guide` 表 (specialty=nutrition) | 营养师可查看用户饮食日志并给出改善建议 |
+
+---
+
+## 8. 实施优先级建议
+
+| 阶段 | 模块 | 核心交付 | 估算 |
+|------|------|---------|------|
+| **P0 基础** | 数据模型 + DDL | 4 张新表 + 1 个扩展字段 | 1 天 |
+| | `databaseInitializer` 建表 | 自动化迁移 | 0.5 天 |
+| **P1 核心** | `MealRecommendService` | 聚合 + DB 过滤 + Dify 编排 | 2 天 |
+| | `MealRecommendController` | `/meal/recommend` + `/meal/replace` | 1 天 |
+| | 食材/食谱管理后台 | Web 管理端 CRUD | 2 天 |
+| | 应季食材配置 | 月度映射管理 | 0.5 天 |
+| **P2 用户功能** | 偏好设置页 | 预算/口味/过敏/烹饪能力 | 1 天 |
+| | 饮食日志页 | 餐次记录 + 营养素自动计算 | 2 天 |
+| | 食谱推荐首页 | 周食谱日历 + 一键换菜 | 2 天 |
+| | 食谱详情页 | 食材/步骤/营养分析 | 1 天 |
+| **P3 增强** | 外部 API 集成 | Spoonacular 批量导入 | 1 天 |
+| | 购买食材 | 关联商城商品 | 1 天 |
+| | 营养师查看 | 规划师端饮食日志查看 | 1 天 |
+
+---
+
+## 9. 风险与注意事项
+
+| 风险 | 影响 | 缓解措施 |
+|------|------|---------|
+| Dify AI 输出格式不稳定 | 推荐结果解析失败 | 后端做强校验 + fallback 到模板推荐 |
+| 食品数据量不足 | 候选集太小 | 运营初始化导入 ≥ 200 种食材 + 50 道食谱 |
+| 预算过滤过严 | 无候选结果 | 预算等级逐级放宽(先 ≤ 预算,再 ≤ 预算+1 级) |
+| 用户不习惯记录饮食 | 饮食日志数据稀疏 | 初期仅记录"今天吃了啥"(简单勾选),不做强制 |
+| 营养素数据准确性 | 营养计算有偏差 | 初始数据标注"仅供参考",数值来自公开数据库 |
+
+---
+
+## 10. 关键设计决策记录
+
+| 决策 | 选择 | 备选 | 理由 |
+|------|------|------|------|
+| 推荐架构 | 混合 (DB+Dify) | 纯 AI / 纯算法 | 兼顾可控性和智能性 |
+| 食品数据库 | 食材 + 食谱 | 仅食材 | 食谱预设可复用,减少 AI 生成不稳定性 |
+| 预算获取 | 用户手动设定 | 从消费记录推算 | 简单直接,数据准确 |
+| 应季数据 | 运营维护月度表 | 外部 API | 国内应季数据 API 选择少,运营可控 |
+| 饮食日志 | 详细记录 | 简单勾选 | 用户需求选了详细日志 |
+| 外部 API | 初始化导入用 | 运行时依赖 | 避免运行时外部依赖的延迟和费用 |
+| 一回换一菜 | 同品类随机替换 | 重新调用 AI | <100ms 响应,不依赖 AI |