소스 검색

feat(frontend): TabBar重构与健康报告页面

- 自定义TabBar组件重构,增强视觉效果
- 新增健康报告详情页(body/health-report.vue)
- 家长首页(parent-index)大改版,增加营养板块
- 扩展API接口支持营养功能
- pages.json路由配置更新
Sisyphus 2 달 전
부모
커밋
8b6d0f8163

+ 3 - 3
cfc-frontend/AGENTS.md

@@ -32,9 +32,9 @@ pages/
 
 pages.json 中定义 5 个 TabBar 页面(TabBar 文案,非五维维度名):
 - 首页: `pages/index/index` - 角色分流入口
-- 身: `pages/body/index` - 身体健康维度
-- 心智: `pages/mind/index` - 心智维度
-- 关系: `pages/action/index` - 对应五维"行"(behavior),行为决定关系
+- 身: `pages/body/index` - 身体健康维度
+- 智: `pages/wisdom/index` - 智慧维度(心智测评)
+- 心: `pages/mind/index` - 心理/情感维度
 - 我的: `pages/profile/profile` - 个人设置和积分
 
 ## CONVENTIONS

+ 4 - 4
cfc-frontend/custom-tab-bar/index.vue

@@ -45,9 +45,9 @@ export default {
       currentIndex: 0,
       normalTabs: [
         { text: '首页', pagePath: '/pages/index/index', icon: '/static/tab-home.png', selectedIcon: '/static/tab-home-active.png' },
-        { text: '身', pagePath: '/pages/body/index', icon: '/static/tab-body.png', selectedIcon: '/static/tab-body-active.png' },
-        { text: '心智', pagePath: '/pages/mind/index', icon: '/static/tab-mind.png', selectedIcon: '/static/tab-mind-active.png' },
-        { text: '行动', pagePath: '/pages/action/index', icon: '/static/tab-action.png', selectedIcon: '/static/tab-action-active.png' },
+        { text: '身', pagePath: '/pages/body/index', icon: '/static/tab-body.png', selectedIcon: '/static/tab-body-active.png' },
+        { text: '智', pagePath: '/pages/wisdom/index', icon: '/static/tab-wisdom.png', selectedIcon: '/static/tab-wisdom-active.png' },
+        { text: '心', pagePath: '/pages/mind/index', icon: '/static/tab-mind.png', selectedIcon: '/static/tab-mind-active.png' },
         { text: '我的', pagePath: '/pages/profile/profile', icon: '/static/tab-profile.png', selectedIcon: '/static/tab-profile-active.png' }
       ],
       teacherTabs: [
@@ -92,7 +92,7 @@ export default {
     switchTab(item, index) {
       this.currentIndex = index
       // 原生TabBar页使用switchTab,非Tab页使用redirectTo(比reLaunch轻量)
-      const nativeTabPages = ['/pages/index/index', '/pages/body/index', '/pages/mind/index', '/pages/action/index', '/pages/profile/profile']
+      const nativeTabPages = ['/pages/index/index', '/pages/body/index', '/pages/wisdom/index', '/pages/mind/index', '/pages/profile/profile']
       if (nativeTabPages.includes(item.pagePath)) {
         uni.switchTab({ url: item.pagePath })
       } else {

+ 29 - 24
cfc-frontend/pages.json

@@ -62,7 +62,7 @@
     {
       "path": "pages/body/index",
       "style": {
-        "navigationBarTitleText": "身"
+        "navigationBarTitleText": "身"
       }
     },
     {
@@ -96,10 +96,16 @@
         "navigationBarTitleText": "健康报告"
       }
     },
+    {
+      "path": "pages/health/nutrition-profile",
+      "style": {
+        "navigationBarTitleText": "营养档案"
+      }
+    },
     {
       "path": "pages/mind/index",
       "style": {
-        "navigationBarTitleText": "心智"
+        "navigationBarTitleText": "心"
       }
     },
     {
@@ -139,6 +145,19 @@
         "navigationBarTitleText": "行动"
       }
     },
+    // ===== 智慧维度(从分包提级为主包,TabBar 页面)=====
+    {
+      "path": "pages/wisdom/index",
+      "style": {
+        "navigationBarTitleText": "智慧"
+      }
+    },
+    {
+      "path": "pages/wisdom/cognitive-report",
+      "style": {
+        "navigationBarTitleText": "认知测评报告"
+      }
+    },
     {
       "path": "pages/action/member-action-detail",
       "style": {
@@ -570,20 +589,6 @@
         }
       ]
     },
-    // ===== 智慧维度 =====
-    {
-      "root": "pages/wisdom",
-      "pages": [
-        {
-          "path": "index",
-          "style": { "navigationBarTitleText": "智慧" }
-        },
-        {
-          "path": "cognitive-report",
-          "style": { "navigationBarTitleText": "认知测评报告" }
-        }
-      ]
-    },
     // ===== 会员服务 =====
     {
       "root": "pages/membership",
@@ -663,22 +668,22 @@
       },
       {
         "pagePath": "pages/body/index",
-        "text": "身",
+        "text": "身",
         "iconPath": "static/tab-body.png",
         "selectedIconPath": "static/tab-body-active.png"
       },
+      {
+        "pagePath": "pages/wisdom/index",
+        "text": "智",
+        "iconPath": "static/tab-wisdom.png",
+        "selectedIconPath": "static/tab-wisdom-active.png"
+      },
       {
         "pagePath": "pages/mind/index",
-        "text": "心智",
+        "text": "心",
         "iconPath": "static/tab-mind.png",
         "selectedIconPath": "static/tab-mind-active.png"
       },
-      {
-        "pagePath": "pages/action/index",
-        "text": "行动",
-        "iconPath": "static/tab-action.png",
-        "selectedIconPath": "static/tab-action-active.png"
-      },
       {
         "pagePath": "pages/profile/profile",
         "text": "我的",

+ 250 - 0
cfc-frontend/pages/body/health-report.vue

@@ -93,6 +93,48 @@
       </view>
     </view>
 
+    <!-- ===== 营养缺乏分析 ===== -->
+    <view class="section" v-if="deficiencyRecords && deficiencyRecords.length > 0">
+      <view class="section-title">
+        <text>🔬 营养缺乏分析</text>
+        <text class="section-sub">{{ deficiencyRecords.length }}项</text>
+      </view>
+      <view class="deficiency-list">
+        <view class="deficiency-card" v-for="dr in deficiencyRecords" :key="dr.nutrient">
+          <view class="deficiency-header">
+            <text class="deficiency-nutrient">{{ dr.nutrient }}</text>
+            <view class="deficiency-type-tag" :class="'dtype-' + dr.deficiencyType">
+              <text>{{ getDeficiencyTypeLabel(dr.deficiencyType) }}</text>
+            </view>
+          </view>
+          <view class="deficiency-severity-row">
+            <view class="severity-bar">
+              <view class="severity-fill" :style="{ width: (dr.severity * 100) + '%', background: getSeverityColor(dr.severity) }"></view>
+            </view>
+            <text class="severity-val">{{ dr.severity }}</text>
+          </view>
+          <view class="deficiency-meta" v-if="dr.indicatorName">
+            <text class="meta-label">关联指标:</text>
+            <text class="meta-value">{{ dr.indicatorName }} ({{ dr.indicatorValue }})</text>
+          </view>
+          <view class="deficiency-foods" v-if="dr.foodSuggestions">
+            <text class="foods-label">推荐食物:</text>
+            <text class="foods-value">{{ dr.foodSuggestions }}</text>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <!-- ===== 五维雷达图 (菌群报告) ===== -->
+    <view class="section" v-if="report && report.reportType === 'gut_flora' && radarDimensions.length > 0">
+      <view class="section-title">
+        <text>五维雷达图</text>
+      </view>
+      <view class="radar-wrap">
+        <canvas canvas-id="radarCanvas" id="radarCanvas" class="radar-canvas" @touchstart.prevent></canvas>
+      </view>
+    </view>
+
     <!-- 底部占位 -->
     <view class="bottom-spacer"></view>
 
@@ -106,6 +148,7 @@
 
 <script>
 import config from '@/config.js'
+import { getDeficiencyAnalysis } from '@/utils/api.js'
 
 const healthRequest = function(url, data) {
   var token = uni.getStorageSync('token')
@@ -140,6 +183,7 @@ export default {
       report: null,
       reportDetail: null,
       indicators: [],
+      deficiencyRecords: [],
       loading: false
     }
   },
@@ -234,6 +278,17 @@ export default {
         }
       }
       return items
+    },
+    radarDimensions: function() {
+      if (!this.report) return []
+      var r = this.report
+      var dims = []
+      if (r.overallScore != null) dims.push({ label: '综合', value: r.overallScore })
+      if (r.gutHealthScore != null) dims.push({ label: '肠道', value: r.gutHealthScore })
+      if (r.nutritionScore != null) dims.push({ label: '营养', value: r.nutritionScore })
+      if (r.chronicDiseaseScore != null) dims.push({ label: '慢病', value: r.chronicDiseaseScore })
+      if (r.gutBalanceScore != null) dims.push({ label: '菌群平衡', value: r.gutBalanceScore })
+      return dims
     }
   },
   onLoad: function(options) {
@@ -243,6 +298,11 @@ export default {
     this.reportId = reportId ? parseInt(reportId) : null
     this.loadData()
   },
+  onReady: function() {
+    if (this.radarDimensions.length >= 3) {
+      this.drawRadarChart()
+    }
+  },
   methods: {
     loadData: function() {
       var self = this
@@ -262,6 +322,7 @@ export default {
         if (results[0] && results[0].data) {
           self.report = results[0].data
           self.reportId = self.report.id
+          self.loadDeficiencyRecords()
           // Load indicators after getting reportId
           if (self.reportId) {
             healthRequest('/api/health/indicator/list', { reportId: self.reportId }).then(function(res) {
@@ -294,6 +355,96 @@ export default {
         url: '/pages/ai/chat?mode=nutrition&reportId=' + this.reportId + '&firstMessage=' + encodeURIComponent('请根据这份菌群检测报告,分析我孩子的营养状况并提出建议')
       })
     },
+    loadDeficiencyRecords: function() {
+      if (!this.childId) return
+      var self = this
+      getDeficiencyAnalysis(this.childId).then(function(res) {
+        if (res.code === 200 && res.data) {
+          self.deficiencyRecords = res.data
+        }
+      }).catch(function(e) {
+        console.log('获取缺乏分析失败', e)
+      })
+    },
+    getDeficiencyTypeLabel: function(type) {
+      if (type === 'deficiency') return '缺乏'
+      if (type === 'excess') return '偏高'
+      if (type === 'borderline') return '边界值'
+      return '未分类'
+    },
+    getSeverityColor: function(severity) {
+      if (severity >= 0.7) return '#EF4444'
+      if (severity >= 0.3) return '#F59E0B'
+      return '#10B981'
+    },
+    drawRadarChart: function() {
+      var dims = this.radarDimensions
+      if (!dims || dims.length < 3) return
+      var ctx = uni.createCanvasContext('radarCanvas', this)
+      var cw = 300
+      var ch = 300
+      var cx = cw / 2
+      var cy = ch / 2
+      var maxR = 110
+      var n = dims.length
+      var angleStep = (2 * Math.PI) / n
+
+      for (var level = 1; level <= 5; level++) {
+        var r = maxR * (level / 5)
+        ctx.setStrokeStyle('#E5E7EB')
+        ctx.setLineWidth(1)
+        ctx.beginPath()
+        for (var j = 0; j < n; j++) {
+          var a = j * angleStep - Math.PI / 2
+          var x = cx + r * Math.cos(a)
+          var y = cy + r * Math.sin(a)
+          if (j === 0) ctx.moveTo(x, y)
+          else ctx.lineTo(x, y)
+        }
+        ctx.closePath()
+        ctx.stroke()
+      }
+
+      for (var k = 0; k < n; k++) {
+        var a2 = k * angleStep - Math.PI / 2
+        ctx.beginPath()
+        ctx.moveTo(cx, cy)
+        ctx.lineTo(cx + maxR * Math.cos(a2), cy + maxR * Math.sin(a2))
+        ctx.setStrokeStyle('#E5E7EB')
+        ctx.setLineWidth(1)
+        ctx.stroke()
+      }
+
+      ctx.beginPath()
+      for (var m = 0; m < n; m++) {
+        var angle = m * angleStep - Math.PI / 2
+        var val = dims[m].value || 0
+        var rr = maxR * (val / 100)
+        var px = cx + rr * Math.cos(angle)
+        var py = cy + rr * Math.sin(angle)
+        if (m === 0) ctx.moveTo(px, py)
+        else ctx.lineTo(px, py)
+      }
+      ctx.closePath()
+      ctx.setFillStyle('rgba(249, 115, 22, 0.2)')
+      ctx.fill()
+      ctx.setStrokeStyle('#F97316')
+      ctx.setLineWidth(2)
+      ctx.stroke()
+
+      for (var q = 0; q < n; q++) {
+        var angle3 = q * angleStep - Math.PI / 2
+        var lx = cx + (maxR + 20) * Math.cos(angle3)
+        var ly = cy + (maxR + 20) * Math.sin(angle3)
+        ctx.setFontSize(11)
+        ctx.setFillStyle('#6B7280')
+        ctx.setTextAlign('center')
+        ctx.setTextBaseline('middle')
+        ctx.fillText(dims[q].label + ' ' + (dims[q].value || '--'), lx, ly)
+      }
+
+      ctx.draw()
+    },
     formatDate: function(dateStr) {
       if (!dateStr) return '--'
       try {
@@ -617,4 +768,103 @@ export default {
   font-size: 20rpx;
   color: #888;
 }
+
+/* ===== 营养缺乏分析 ===== */
+.deficiency-list {
+  display: flex;
+  flex-direction: column;
+}
+.deficiency-card {
+  background: #FAFAFA;
+  border-radius: 16rpx;
+  padding: 20rpx;
+  margin-bottom: 12rpx;
+}
+.deficiency-header {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 12rpx;
+}
+.deficiency-nutrient {
+  font-size: 28rpx;
+  font-weight: bold;
+  color: #333;
+}
+.deficiency-type-tag {
+  font-size: 22rpx;
+  padding: 4rpx 16rpx;
+  border-radius: 16rpx;
+  background: #FFF3E0;
+  color: #E65100;
+}
+.dtype-deficiency {
+  background: #FEE2E2;
+  color: #DC2626;
+}
+.dtype-excess {
+  background: #FFF3E0;
+  color: #E65100;
+}
+.dtype-borderline {
+  background: #FEF3C7;
+  color: #D97706;
+}
+.dtype-unclassified {
+  background: #F3F4F6;
+  color: #6B7280;
+}
+.deficiency-severity-row {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin-bottom: 8rpx;
+}
+.severity-bar {
+  flex: 1;
+  height: 12rpx;
+  background: #F3F4F6;
+  border-radius: 6rpx;
+  overflow: hidden;
+  margin-right: 16rpx;
+}
+.severity-fill {
+  height: 100%;
+  border-radius: 6rpx;
+}
+.severity-val {
+  font-size: 24rpx;
+  color: #9CA3AF;
+  width: 60rpx;
+  text-align: right;
+}
+.deficiency-meta {
+  font-size: 22rpx;
+  color: #6B7280;
+  margin-bottom: 6rpx;
+}
+.deficiency-foods {
+  font-size: 22rpx;
+  color: #374151;
+  margin-top: 8rpx;
+}
+.foods-label {
+  color: #9CA3AF;
+}
+.foods-value {
+  color: #2E7D32;
+}
+
+/* ===== 雷达图 ===== */
+.radar-wrap {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+}
+.radar-canvas {
+  width: 300px;
+  height: 300px;
+  margin: 0 auto;
+}
 </style>

+ 11 - 0
cfc-frontend/pages/body/index.vue

@@ -207,6 +207,10 @@
         <text class="action-icon">🏃</text>
         <text class="action-text">健康打卡</text>
       </view>
+      <view class="action-btn" @click="goToNutritionProfile">
+        <text class="action-icon">🥗</text>
+        <text class="action-text">营养档案</text>
+      </view>
     </view>
 
     <!-- 底部占位 -->
@@ -517,6 +521,13 @@ export default {
       }
       uni.navigateTo({ url: '/pages/body/checkin?childId=' + this.activeChildId })
     },
+    goToNutritionProfile: function() {
+      if (!this.activeChildId) {
+        uni.showToast({ title: '请先选择孩子', icon: 'none' })
+        return
+      }
+      uni.navigateTo({ url: '/pages/health/nutrition-profile?childId=' + this.activeChildId })
+    },
     goToUploadReport: function() {
       if (!this.activeChildId) {
         uni.showToast({ title: '请先选择孩子', icon: 'none' })

+ 669 - 0
cfc-frontend/pages/health/nutrition-profile.vue

@@ -0,0 +1,669 @@
+<template>
+  <view class="nutrition-profile">
+    <view class="nav-bar">
+      <text class="nav-back" @click="goBack">‹ 返回</text>
+      <text class="nav-title">营养档案</text>
+      <view class="nav-placeholder"></view>
+    </view>
+
+    <view class="profile-header" v-if="childName">
+      <view class="child-info">
+        <text class="child-name">{{ childName }}</text>
+        <text class="child-age" v-if="childAge">{{ childAge }}岁</text>
+      </view>
+    </view>
+
+    <view class="score-section" v-if="scores">
+      <view class="main-score-ring">
+        <view class="score-ring-outer">
+          <view class="score-ring-inner">
+            <text class="score-value">{{ scores.overall || '--' }}</text>
+            <text class="score-label">综合评分</text>
+          </view>
+        </view>
+      </view>
+      <view class="score-items">
+        <view class="score-item">
+          <text class="score-num">{{ scores.gutHealth || '--' }}</text>
+          <text class="score-name">肠道健康</text>
+        </view>
+        <view class="score-item">
+          <text class="score-num">{{ scores.nutrition || '--' }}</text>
+          <text class="score-name">营养状况</text>
+        </view>
+        <view class="score-item">
+          <text class="score-num">{{ scores.chronicDisease || '--' }}</text>
+          <text class="score-name">慢病控制</text>
+        </view>
+        <view class="score-item">
+          <text class="score-num">{{ scores.gutBalance || '--' }}</text>
+          <text class="score-name">菌群平衡</text>
+        </view>
+      </view>
+    </view>
+
+    <view class="empty-state" v-if="!scores && !loading">
+      <text class="empty-icon">📋</text>
+      <text class="empty-title">暂无健康报告</text>
+      <text class="empty-desc">完成首次健康检测后,这里将展示孩子的营养档案</text>
+      <view class="empty-btn" @click="goToAssessment">
+        <text class="empty-btn-text">预约健康检测</text>
+      </view>
+    </view>
+
+    <view class="section" v-if="abnormalIndicators && abnormalIndicators.length > 0">
+      <view class="section-title-wrap">
+        <text class="section-title">⚠️ 需关注指标</text>
+        <text class="section-count">{{ abnormalIndicators.length }}项</text>
+      </view>
+      <view class="indicator-list">
+        <view class="indicator-item" v-for="item in abnormalIndicators" :key="item.id">
+          <view class="indicator-left">
+            <text class="indicator-emoji">{{ getStatusEmoji(item.status) }}</text>
+            <view class="indicator-info">
+              <text class="indicator-name">{{ item.indicatorName }}</text>
+              <text class="indicator-value">{{ item.indicatorValue }}{{ item.unit }} · {{ item.status }}</text>
+            </view>
+          </view>
+        </view>
+      </view>
+      <view class="section-more" @click="goToIndicators">
+        <text class="section-more-text">查看全部指标 →</text>
+      </view>
+    </view>
+
+    <view class="section" v-if="scoreTrend && scoreTrend.length > 1">
+      <view class="section-title-wrap">
+        <text class="section-title">📊 变化趋势</text>
+      </view>
+      <view class="trend-chart">
+        <canvas canvas-id="trendCanvas" id="trendCanvas" class="trend-canvas" @touchstart.prevent></canvas>
+      </view>
+      <view class="trend-legend">
+        <view class="legend-item">
+          <view class="legend-color" style="background:#F97316"></view>
+          <text class="legend-text">综合评分</text>
+        </view>
+        <view class="legend-item">
+          <view class="legend-color" style="background:#10B981"></view>
+          <text class="legend-text">肠道健康</text>
+        </view>
+      </view>
+    </view>
+
+    <view class="section" v-if="deficiencyRecords && deficiencyRecords.length > 0">
+      <view class="section-title-wrap">
+        <text class="section-title">🔬 营养缺乏分析</text>
+        <text class="section-count">{{ deficiencyRecords.length }}项</text>
+      </view>
+      <view class="deficiency-list">
+        <view class="deficiency-item" v-for="record in deficiencyRecords" :key="record.nutrient">
+          <view class="deficiency-info">
+            <text class="deficiency-name">{{ record.nutrient }}</text>
+            <text class="deficiency-level">{{ getDeficiencyLabel(record) }}</text>
+          </view>
+          <view class="deficiency-bar">
+            <view class="deficiency-fill" :style="{ width: (record.severity * 100) + '%', background: getSeverityColor(record.severity) }"></view>
+          </view>
+          <view class="deficiency-foods" v-if="record.foodSuggestions">
+            <text>推荐食物:{{ record.foodSuggestions }}</text>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <view class="section" v-if="latestReports && latestReports.length > 0">
+      <view class="section-title-wrap">
+        <text class="section-title">📋 历史报告</text>
+      </view>
+      <view class="report-list">
+        <view class="report-item" v-for="r in latestReports" :key="r.id" @click="goToReport(r.id)">
+          <view class="report-item-left">
+            <text class="report-item-type">{{ getReportTypeName(r.reportType) }}</text>
+            <text class="report-item-date">{{ formatDate(r.reportDate) }}</text>
+          </view>
+          <view class="report-item-score">
+            <text class="report-score-val">{{ r.overallScore || '--' }}</text>
+            <text class="report-score-label">分</text>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <view class="bottom-spacer"></view>
+
+    <view class="fab" @click="goToAIRecommend" v-if="scores">
+      <text class="fab-icon">🥗</text>
+      <text class="fab-text">获取营养方案</text>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getChildNutritionProfile, getDeficiencyAnalysis } from '@/utils/api.js'
+
+export default {
+  data() {
+    return {
+      childId: null,
+      childName: '',
+      childAge: '',
+      childGender: '',
+      scores: null,
+      abnormalIndicators: [],
+      scoreTrend: [],
+      deficiencyRecords: [],
+      latestReports: [],
+      loading: false
+    }
+  },
+  onLoad: function(options) {
+    this.childId = options.childId ? parseInt(options.childId) : null
+    if (!this.childId) {
+      var savedChildId = uni.getStorageSync('currentChildId')
+      if (savedChildId) this.childId = parseInt(savedChildId)
+    }
+    if (this.childId) {
+      this.loadProfile()
+    }
+  },
+  onReady: function() {
+    if (this.scoreTrend && this.scoreTrend.length > 1) {
+      this.drawTrendChart()
+    }
+  },
+  methods: {
+    loadProfile: function() {
+      var self = this
+      self.loading = true
+      uni.showLoading({ title: '加载中...' })
+      getChildNutritionProfile(self.childId).then(function(res) {
+        uni.hideLoading()
+        self.loading = false
+        if (res.code === 200 && res.data) {
+          var d = res.data
+          self.childName = d.childName || ''
+          self.childAge = d.childAge || ''
+          self.childGender = d.childGender || ''
+          self.scores = d.scores
+          self.abnormalIndicators = d.abnormalIndicators || []
+          self.scoreTrend = d.scoreTrend || []
+          self.latestReports = d.latestReports || []
+          if (d.nutritionProfile) {
+            self.nutritionProfile = d.nutritionProfile
+          }
+        }
+      }).catch(function(e) {
+        uni.hideLoading()
+        self.loading = false
+        console.log('加载营养档案失败', e)
+      })
+
+      getDeficiencyAnalysis(self.childId).then(function(res) {
+        if (res.code === 200 && res.data) {
+          self.deficiencyRecords = res.data
+        }
+      }).catch(function(e) {
+        console.log('加载缺乏分析失败', e)
+      })
+    },
+    drawTrendChart: function() {
+      var ctx = uni.createCanvasContext('trendCanvas', this)
+      var trend = this.scoreTrend
+      if (!trend || trend.length < 2) return
+      var cw = 690
+      var ch = 300
+      var padding = 40
+      var xStep = (cw - padding * 2) / (trend.length - 1)
+      var yMin = 0
+      var yMax = 100
+
+      ctx.setStrokeStyle('#F0F0F0')
+      ctx.setLineWidth(1)
+      for (var i = 0; i <= 4; i++) {
+        var y = padding + (ch - padding * 2) * (i / 4)
+        ctx.beginPath()
+        ctx.moveTo(padding, y)
+        ctx.lineTo(cw - padding, y)
+        ctx.stroke()
+      }
+
+      var drawLine = function(key, color) {
+        ctx.setStrokeStyle(color)
+        ctx.setLineWidth(3)
+        ctx.beginPath()
+        for (var j = 0; j < trend.length; j++) {
+          var val = trend[j][key] || 0
+          var x = padding + j * xStep
+          var y = padding + (ch - padding * 2) * (1 - (val - yMin) / (yMax - yMin))
+          if (j === 0) {
+            ctx.moveTo(x, y)
+          } else {
+            ctx.lineTo(x, y)
+          }
+        }
+        ctx.stroke()
+        ctx.draw(true)
+      }
+
+      drawLine('overall', '#F97316')
+      drawLine('gutHealth', '#10B981')
+    },
+    getStatusEmoji: function(status) {
+      if (status === '偏低') return '🔴'
+      if (status === '偏高') return '🔴'
+      if (status === '注意') return '🟡'
+      return '🟢'
+    },
+    getDeficiencyLabel: function(record) {
+      if (record.deficiencyType === 'deficiency') {
+        if (record.severity >= 0.7) return '严重缺乏'
+        if (record.severity >= 0.3) return '缺乏'
+        return '轻度不足'
+      }
+      if (record.deficiencyType === 'excess') return '偏高'
+      if (record.deficiencyType === 'borderline') return '边界值'
+      return '未分类'
+    },
+    getSeverityColor: function(severity) {
+      if (severity >= 0.7) return '#EF4444'
+      if (severity >= 0.3) return '#F59E0B'
+      return '#10B981'
+    },
+    getReportTypeName: function(type) {
+      if (type === 'gut_flora') return '肠道检测'
+      if (type === 'physical_exam') return '体检报告'
+      if (type === 'blood') return '血液检测'
+      return type || '健康报告'
+    },
+    formatDate: function(dateStr) {
+      if (!dateStr) return ''
+      try {
+        var d = new Date(dateStr)
+        var m = ('' + (d.getMonth() + 1)).padStart(2, '0')
+        var day = ('' + d.getDate()).padStart(2, '0')
+        return m + '-' + day
+      } catch (e) {
+        return dateStr
+      }
+    },
+    goBack: function() {
+      uni.navigateBack()
+    },
+    goToReport: function(id) {
+      uni.navigateTo({ url: '/pages/body/health-report?reportId=' + id + '&childId=' + this.childId })
+    },
+    goToIndicators: function() {
+      uni.navigateTo({ url: '/pages/body/health-report?childId=' + this.childId })
+    },
+    goToAssessment: function() {
+      uni.navigateTo({ url: '/pages/assessment/apply' })
+    },
+    goToAIRecommend: function() {
+      uni.navigateTo({ url: '/pages/meal/recommend?childId=' + this.childId })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.nutrition-profile {
+  min-height: 100vh;
+  background: #FFF7ED;
+  padding-bottom: 40rpx;
+}
+
+.nav-bar {
+  position: sticky;
+  top: 0;
+  background: #fff;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  padding: 20rpx 30rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.06);
+  z-index: 100;
+}
+.nav-back {
+  font-size: 32rpx;
+  color: #F97316;
+}
+.nav-title {
+  font-size: 32rpx;
+  font-weight: bold;
+  color: #333;
+}
+.nav-placeholder {
+  width: 80rpx;
+}
+
+.profile-header {
+  padding: 40rpx 30rpx 20rpx;
+}
+.child-info {
+  display: flex;
+  flex-direction: column;
+}
+.child-name {
+  font-size: 40rpx;
+  font-weight: bold;
+  color: #1F2937;
+}
+.child-age {
+  font-size: 26rpx;
+  color: #6B7280;
+  margin-top: 8rpx;
+}
+
+.score-section {
+  margin: 0 20rpx;
+  background: white;
+  border-radius: 24rpx;
+  padding: 30rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.05);
+}
+.main-score-ring {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  margin-bottom: 30rpx;
+}
+.score-ring-outer {
+  width: 200rpx;
+  height: 200rpx;
+  border-radius: 100rpx;
+  border: 12rpx solid #F97316;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.score-ring-inner {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+.score-value {
+  font-size: 56rpx;
+  font-weight: bold;
+  color: #F97316;
+}
+.score-label {
+  font-size: 24rpx;
+  color: #9CA3AF;
+}
+.score-items {
+  display: flex;
+  flex-direction: row;
+  justify-content: space-around;
+  flex-wrap: wrap;
+}
+.score-item {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  width: 25%;
+}
+.score-num {
+  font-size: 32rpx;
+  font-weight: bold;
+  color: #374151;
+}
+.score-name {
+  font-size: 22rpx;
+  color: #9CA3AF;
+  margin-top: 4rpx;
+}
+
+.empty-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 80rpx 30rpx;
+}
+.empty-icon {
+  font-size: 80rpx;
+}
+.empty-title {
+  font-size: 36rpx;
+  font-weight: bold;
+  color: #374151;
+  margin-top: 20rpx;
+}
+.empty-desc {
+  font-size: 28rpx;
+  color: #9CA3AF;
+  text-align: center;
+  margin: 16rpx 0 30rpx;
+}
+.empty-btn {
+  background: #F97316;
+  border-radius: 40rpx;
+  padding: 20rpx 60rpx;
+}
+.empty-btn-text {
+  color: white;
+  font-size: 28rpx;
+}
+
+.section {
+  background: white;
+  border-radius: 24rpx;
+  margin: 20rpx;
+  padding: 30rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.05);
+}
+.section-title-wrap {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 20rpx;
+}
+.section-title {
+  font-size: 32rpx;
+  font-weight: bold;
+  color: #374151;
+}
+.section-count {
+  font-size: 24rpx;
+  color: #9CA3AF;
+}
+.indicator-list {
+  display: flex;
+  flex-direction: column;
+}
+.indicator-item {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  padding: 12rpx 0;
+  border-bottom: 1rpx solid #F3F4F6;
+}
+.indicator-item:last-child {
+  border-bottom: none;
+}
+.indicator-left {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+}
+.indicator-emoji {
+  font-size: 28rpx;
+  margin-right: 16rpx;
+}
+.indicator-info {
+  display: flex;
+  flex-direction: column;
+}
+.indicator-name {
+  font-size: 28rpx;
+  color: #374151;
+  font-weight: 500;
+}
+.indicator-value {
+  font-size: 24rpx;
+  color: #9CA3AF;
+  margin-top: 4rpx;
+}
+.section-more {
+  text-align: center;
+  padding-top: 20rpx;
+}
+.section-more-text {
+  color: #F97316;
+  font-size: 26rpx;
+}
+
+.trend-canvas {
+  width: 100%;
+  height: 300rpx;
+}
+.trend-legend {
+  display: flex;
+  flex-direction: row;
+  justify-content: center;
+  margin-top: 16rpx;
+}
+.legend-item {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin-left: 30rpx;
+}
+.legend-item:first-child {
+  margin-left: 0;
+}
+.legend-color {
+  width: 20rpx;
+  height: 20rpx;
+  border-radius: 4rpx;
+  margin-right: 8rpx;
+}
+.legend-text {
+  font-size: 24rpx;
+  color: #6B7280;
+}
+
+.deficiency-list {
+  display: flex;
+  flex-direction: column;
+}
+.deficiency-item {
+  display: flex;
+  flex-direction: column;
+  padding: 16rpx 0;
+  border-bottom: 1rpx solid #F3F4F6;
+}
+.deficiency-item:last-child {
+  border-bottom: none;
+}
+.deficiency-info {
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 10rpx;
+}
+.deficiency-name {
+  font-size: 28rpx;
+  color: #374151;
+  font-weight: 500;
+}
+.deficiency-level {
+  font-size: 24rpx;
+  color: #9CA3AF;
+}
+.deficiency-bar {
+  height: 12rpx;
+  background: #F3F4F6;
+  border-radius: 6rpx;
+  overflow: hidden;
+}
+.deficiency-fill {
+  height: 100%;
+  border-radius: 6rpx;
+}
+.deficiency-foods {
+  font-size: 22rpx;
+  color: #6B7280;
+  margin-top: 8rpx;
+}
+
+.report-list {
+  display: flex;
+  flex-direction: column;
+}
+.report-item {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  padding: 16rpx 0;
+  border-bottom: 1rpx solid #F3F4F6;
+}
+.report-item:last-child {
+  border-bottom: none;
+}
+.report-item:active {
+  opacity: 0.7;
+}
+.report-item-left {
+  display: flex;
+  flex-direction: column;
+}
+.report-item-type {
+  font-size: 28rpx;
+  color: #374151;
+  font-weight: 500;
+}
+.report-item-date {
+  font-size: 22rpx;
+  color: #9CA3AF;
+  margin-top: 4rpx;
+}
+.report-item-score {
+  display: flex;
+  flex-direction: row;
+  align-items: baseline;
+}
+.report-score-val {
+  font-size: 36rpx;
+  font-weight: bold;
+  color: #F97316;
+}
+.report-score-label {
+  font-size: 20rpx;
+  color: #9CA3AF;
+  margin-left: 4rpx;
+}
+
+.fab {
+  position: fixed;
+  bottom: 40rpx;
+  left: 50%;
+  transform: translateX(-50%);
+  background: linear-gradient(135deg, #F97316, #FB923C);
+  color: white;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  padding: 20rpx 40rpx;
+  border-radius: 50rpx;
+  box-shadow: 0 8rpx 24rpx rgba(249, 115, 22, 0.4);
+  z-index: 200;
+}
+.fab-icon {
+  font-size: 36rpx;
+  margin-right: 12rpx;
+}
+.fab-text {
+  font-size: 30rpx;
+  font-weight: 600;
+}
+
+.bottom-spacer {
+  height: 130rpx;
+}
+</style>

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

@@ -1,11 +1,5 @@
 <template>
 <view class="container">
-<!-- 首次登录引导浮层 -->
-<OnboardingGuide
-  role="parent"
-  :show="showOnboarding"
-  @close="showOnboarding = false" />
-
 <!-- 加载状态 -->
 <BaseLoading :loading="loading" text="正在加载..." />
 
@@ -214,7 +208,38 @@
         </view>
       </view>
 
-      
+      <!-- ===== 重要关系(外部通讯录) ===== -->
+      <view class="contact-section" v-if="!loading && familyMembersVisible.length > 0">
+        <view class="contact-header">
+          <text class="contact-section-title">重要关系</text>
+          <view class="contact-header-right">
+            <text class="contact-more" @click="loadContacts">刷新</text>
+            <text class="contact-import-btn" @click="onShowImport">+ 导入</text>
+          </view>
+        </view>
+        <scroll-view class="contact-scroll" scroll-x enable-flex>
+          <view class="contact-scroll-inner">
+            <view class="contact-card-wrap" v-for="item in contactList" :key="item.id" @click="onContactClick(item)">
+              <ContactCard :contact="item" />
+            </view>
+            <view class="contact-card-wrap contact-add-card" @click="onShowImport">
+              <view class="add-card-inner">
+                <text class="add-card-icon">+</text>
+                <text class="add-card-label">导入通讯录</text>
+              </view>
+            </view>
+          </view>
+        </scroll-view>
+        <view class="contact-empty" v-if="contactList.length === 0">
+          <text class="contact-empty-text">暂无重要联系人,点击导入添加</text>
+        </view>
+      </view>
+
+      <!-- 导入联系人弹窗 -->
+      <ContactImport
+        :visible="showImportModal"
+        @close="onCloseImport"
+        @success="onImportSuccess" />
 
       <!-- ===== 推荐活动 ===== -->
       <DimensionActivities
@@ -303,8 +328,7 @@
 </template>
 
 <script>
-import { getFamilyMembers, getChildren, getPendingReviewTasks, getPendingWishes, approveTask, rejectTask, getChildCompletionStats, getParentDashboard, getFamilyEnergySandbox, getEnergyOverview, getVisibleFamilyMembers, getActivityList, getProductsByDomain } from '../../utils/api.js'
-import OnboardingGuide from '../../components/OnboardingGuide.vue'
+import { getFamilyMembers, getChildren, getPendingReviewTasks, getPendingWishes, approveTask, rejectTask, getChildCompletionStats, getParentDashboard, getFamilyEnergySandbox, getEnergyOverview, getVisibleFamilyMembers, getActivityList, getProductsByDomain, getContactList } from '../../utils/api.js'
 import PageBanner from '../../components/PageBanner.vue'
 import DimensionActivities from '../../components/DimensionActivities.vue'
 import DimensionProducts from '../../components/DimensionProducts.vue'
@@ -315,9 +339,11 @@ import BaseEmpty from '../../components/BaseEmpty.vue'
 import BaseLoading from '../../components/BaseLoading.vue'
 import FamilyRelationGraph from '../../components/FamilyRelationGraph.vue'
 import WuxingSandbox from '../../components/wuxing-sandbox.vue'
+import ContactCard from '../../components/ContactCard.vue'
+import ContactImport from '../../components/ContactImport.vue'
 
 export default {
-  components: { OnboardingGuide, PageBanner, DimensionActivities, DimensionProducts, PlayfulCard, PlayfulButton, BaseBadge, BaseEmpty, BaseLoading, FamilyRelationGraph, WuxingSandbox },
+  components: { PageBanner, DimensionActivities, DimensionProducts, PlayfulCard, PlayfulButton, BaseBadge, BaseEmpty, BaseLoading, FamilyRelationGraph, WuxingSandbox, ContactCard, ContactImport },
   data() {
     return {
       nickname: '',
@@ -336,7 +362,6 @@ export default {
       familyTotalPoints: 0,
       // membershipText removed (no real function)
       loading: true,
-      showOnboarding: false,
       currentChildId: '',
       _initialLoadDone: false,
       _familyId: null,
@@ -353,7 +378,9 @@ export default {
       errorEnergy: false,
       errorTasks: false,
       errorActivities: false,
-      errorProducts: false
+      errorProducts: false,
+      contactList: [],
+      showImportModal: false
     }
   },
   computed: {
@@ -419,12 +446,6 @@ export default {
       return
     }
     this.loadData()
-
-    // 首次登录引导
-    var onboardingDone = uni.getStorageSync('_onboardingDone')
-    if (!onboardingDone) {
-      this.showOnboarding = true
-    }
   },
   onShow() {
     // 首次 onShow 紧接 mounted 之后,跳过避免双重加载
@@ -619,6 +640,8 @@ export default {
 
         // 加载家庭成员可见列表
         this.loadFamilyMembersVisible()
+        // 加载外部联系人
+        this.loadContacts()
 
         // 加载五维能量概览(账本模式)
         var overviewChildId = this.currentChildId
@@ -739,7 +762,31 @@ export default {
 
     goToTaskList() { uni.switchTab({ url: '/pages/tasks/tasks' }) },
     manageChildren() { uni.navigateTo({ url: '/pages/profile/children' }) },
-    inviteFriend() { uni.navigateTo({ url: '/pages/parent/invite/index' }) }
+    inviteFriend() { uni.navigateTo({ url: '/pages/parent/invite/index' }) },
+
+    loadContacts: function() {
+      var self = this
+      getContactList({}).then(function(res) {
+        if (res.code === 200 && res.data) {
+          self.contactList = Array.isArray(res.data) ? res.data : []
+        }
+      }).catch(function(e) {
+        console.log('获取联系人失败', e)
+      })
+    },
+    onContactClick: function(item) {
+      uni.navigateTo({ url: '/pages/action/contact-detail?id=' + item.id })
+    },
+    onShowImport: function() {
+      this.showImportModal = true
+    },
+    onCloseImport: function() {
+      this.showImportModal = false
+    },
+    onImportSuccess: function() {
+      this.showImportModal = false
+      this.loadContacts()
+    }
   }
 }
 </script>
@@ -1231,4 +1278,82 @@ export default {
   color: #94A3B8;
   text-align: center;
 }
+
+/* ===== 重要关系(通讯录) ===== */
+.contact-section {
+  margin: 20rpx 20rpx 0;
+}
+.contact-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 16rpx;
+}
+.contact-section-title {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #333;
+}
+.contact-header-right {
+  display: flex;
+  gap: 16rpx;
+  align-items: center;
+}
+.contact-more {
+  font-size: 24rpx;
+  color: #999;
+}
+.contact-import-btn {
+  font-size: 24rpx;
+  color: #F97316;
+  font-weight: 500;
+}
+.contact-scroll {
+  white-space: nowrap;
+  overflow: hidden;
+}
+.contact-scroll-inner {
+  display: flex;
+  flex-direction: row;
+  gap: 16rpx;
+  padding: 8rpx 0 16rpx;
+}
+.contact-card-wrap {
+  flex-shrink: 0;
+  width: 220rpx;
+}
+.contact-card-wrap:active {
+  opacity: 0.8;
+}
+.contact-add-card {
+  width: 160rpx;
+}
+.add-card-inner {
+  width: 160rpx;
+  height: 280rpx;
+  background: #fff;
+  border-radius: 16rpx;
+  border: 2rpx dashed #ddd;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 12rpx;
+}
+.add-card-icon {
+  font-size: 48rpx;
+  color: #ccc;
+}
+.add-card-label {
+  font-size: 22rpx;
+  color: #999;
+}
+.contact-empty {
+  padding: 40rpx 0;
+  text-align: center;
+}
+.contact-empty-text {
+  font-size: 26rpx;
+  color: #ccc;
+}
 </style>

+ 8 - 0
cfc-frontend/utils/api.js

@@ -1328,3 +1328,11 @@ export const getMealLogs = (data) => request('/api/meal/logs', 'POST', data)
 export const getNutritionSummary = (data) => request('/api/meal/nutrition-summary', 'POST', data)
 export const getNutritionProfile = (data) => request('/api/nutrition/profile/get', 'POST', data)
 export const saveNutritionProfile = (data) => request('/api/nutrition/profile/save', 'POST', data)
+
+// ===== 营养档案与缺乏分析 =====
+export const getChildNutritionProfile = (childId) => request('/api/health/nutrition/profile', 'POST', { childId })
+export const getDeficiencyAnalysis = (childId) => request('/api/health/nutrition/deficiency', 'POST', { childId })
+export const getDeficiencySummary = (childId) => request('/api/health/nutrition/deficiency-summary', 'POST', { childId })
+export const getNutritionTrend = (childId) => request('/api/health/nutrition/trend', 'POST', { childId })
+export const getHealthAnalysis = (childId) => request('/api/health/analysis', 'POST', { childId })
+export const getNutritionMappings = (data) => request('/api/health/nutrition/mappings', 'POST', data || {})