Преглед изворни кода

fix: 营养健康模块5项缺陷修复

1. createReport: 补充symptoms字段提取
2. uploadReport: gut_flora段补充symptoms设置+ParsedIndicator新增symptoms字段
3. 新增UserNutritionProfile模块(Mapper/Service/Controller),MealRecommendService改用数据库偏好
4. 小程序新增recipe-detail.vue和preference.vue+pages.json注册+api.js接口
5. filterCandidates增加卡路里上限过滤(>2400kcal排除)
Sisyphus пре 2 месеци
родитељ
комит
b0bfd2aff0

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

@@ -112,6 +112,7 @@ public class HealthReportController {
                 ind.setUnit((String) item.get("unit"));
                 ind.setRefRange((String) item.get("refRange"));
                 ind.setStatus((String) item.get("status"));
+                ind.setSymptoms((String) item.get("symptoms"));
                 ind.setSortOrder(i);
                 indicators.add(ind);
             }
@@ -299,6 +300,7 @@ public class HealthReportController {
                     ind.setIndicatorValue(pi.getIndicatorValue());
                     ind.setStatus(pi.getStatus());
                     ind.setRefRange(pi.getRefRange());
+                    ind.setSymptoms(pi.getSymptoms());
                     ind.setSortOrder(i);
                     indicators.add(ind);
                 }

+ 54 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/UserNutritionProfileController.java

@@ -0,0 +1,54 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.UserNutritionProfile;
+import com.etotem.cfc.service.UserNutritionProfileService;
+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.Map;
+
+@Tag(name = "营养偏好", description = "用户营养偏好管理")
+@RestController
+@RequestMapping("/api/nutrition/profile")
+public class UserNutritionProfileController {
+
+    @Resource
+    private UserNutritionProfileService service;
+
+    @Operation(summary = "获取当前用户营养偏好")
+    @PostMapping("/get")
+    public Result<UserNutritionProfile> get(@RequestAttribute("userId") Long userId) {
+        return Result.success(service.getByUserId(userId));
+    }
+
+    @Operation(summary = "保存营养偏好")
+    @PostMapping("/save")
+    public Result<String> save(@RequestAttribute("userId") Long userId,
+                               @RequestBody Map<String, Object> params) {
+        UserNutritionProfile profile = service.getByUserId(userId);
+        if (profile == null) {
+            profile = new UserNutritionProfile();
+            profile.setUserId(userId);
+        }
+        if (params.get("budgetMonthly") != null) {
+            profile.setBudgetMonthly(Integer.valueOf(params.get("budgetMonthly").toString()));
+        }
+        if (params.get("familyTastePreferences") != null) {
+            profile.setFamilyTastePreferences((String) params.get("familyTastePreferences"));
+        }
+        if (params.get("cuisineStyle") != null) {
+            profile.setCuisineStyle((String) params.get("cuisineStyle"));
+        }
+        if (params.get("mealCount") != null) {
+            profile.setMealCount(Integer.valueOf(params.get("mealCount").toString()));
+        }
+        if (params.get("cookingAbility") != null) {
+            profile.setCookingAbility((String) params.get("cookingAbility"));
+        }
+        service.save(profile);
+        return Result.success("ok");
+    }
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ParsedIndicator.java

@@ -10,6 +10,7 @@ public class ParsedIndicator {
     private String indicatorValue;  // 检测值
     private String status;          // 状态: 正常/偏低/缺乏/偏高/不足等
     private String refRange;        // 参考范围
+    private String symptoms;        // 过量/缺乏相关症状描述
 
     public String getCategory() {
         return category;
@@ -50,4 +51,12 @@ public class ParsedIndicator {
     public void setRefRange(String refRange) {
         this.refRange = refRange;
     }
+
+    public String getSymptoms() {
+        return symptoms;
+    }
+
+    public void setSymptoms(String symptoms) {
+        this.symptoms = symptoms;
+    }
 }

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/UserNutritionProfileMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.UserNutritionProfile;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface UserNutritionProfileMapper extends BaseMapper<UserNutritionProfile> {
+}

+ 18 - 5
cfc-backend/src/main/java/com/etotem/cfc/service/MealRecommendService.java

@@ -33,6 +33,9 @@ public class MealRecommendService {
     @Resource
     private UserMapper userMapper;
 
+    @Resource
+    private UserNutritionProfileService userNutritionProfileService;
+
     public RecommendationContext aggregateContext(Long userId) {
         RecommendationContext ctx = new RecommendationContext();
         ctx.setUserId(userId);
@@ -47,10 +50,11 @@ public class MealRecommendService {
             ));
         }
 
-        ctx.setBudgetMonthly(2000);
-        ctx.setTastePreference("\u6e05\u6de1");
+        UserNutritionProfile profile = userNutritionProfileService.getByUserId(userId);
+        ctx.setBudgetMonthly(profile != null && profile.getBudgetMonthly() != null ? profile.getBudgetMonthly() : 2000);
+        ctx.setTastePreference(profile != null && profile.getFamilyTastePreferences() != null ? profile.getFamilyTastePreferences() : "\u6e05\u6de1");
         ctx.setDietaryRestrictions("\u65e0");
-        ctx.setCuisineStyle("\u4e2d\u5f0f");
+        ctx.setCuisineStyle(profile != null && profile.getCuisineStyle() != null ? profile.getCuisineStyle() : "\u4e2d\u5f0f");
 
         ctx.setNutritionSummary(mealLogService.getNutritionSummary(userId, 7));
         filterCandidates(ctx);
@@ -72,8 +76,17 @@ public class MealRecommendService {
 
         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);
+        List<Recipe> allRecipes = recipeService.listByMealType(null);
+        int maxCaloriesPerMeal = 800;
+        List<Recipe> recipeFiltered = allRecipes.stream()
+                .filter(r -> {
+                    if (r.getTotalCalories() != null && r.getTotalCalories().intValue() > maxCaloriesPerMeal * 3) {
+                        return false;
+                    }
+                    return true;
+                })
+                .collect(Collectors.toList());
+        ctx.setCandidateRecipes(recipeFiltered.size() > 15 ? recipeFiltered.subList(0, 15) : recipeFiltered);
     }
 
     public Food findReplaceCandidate(Long currentFoodId, Long userId) {

+ 34 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/UserNutritionProfileService.java

@@ -0,0 +1,34 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.UserNutritionProfile;
+import com.etotem.cfc.mapper.UserNutritionProfileMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+
+@Slf4j
+@Service
+public class UserNutritionProfileService {
+
+    @Resource
+    private UserNutritionProfileMapper mapper;
+
+    public UserNutritionProfile getByUserId(Long userId) {
+        if (userId == null) return null;
+        return mapper.selectOne(
+            new LambdaQueryWrapper<UserNutritionProfile>()
+                .eq(UserNutritionProfile::getUserId, userId)
+                .last("LIMIT 1")
+        );
+    }
+
+    public void save(UserNutritionProfile profile) {
+        if (profile.getId() == null) {
+            mapper.insert(profile);
+        } else {
+            mapper.updateById(profile);
+        }
+    }
+}

+ 8 - 0
cfc-frontend/pages.json

@@ -631,6 +631,14 @@
         {
           "path": "log",
           "style": { "navigationBarTitleText": "饮食记录" }
+        },
+        {
+          "path": "recipe-detail",
+          "style": { "navigationBarTitleText": "食谱详情" }
+        },
+        {
+          "path": "preference",
+          "style": { "navigationBarTitleText": "饮食偏好设置" }
         }
       ]
     }

+ 242 - 0
cfc-frontend/pages/meal/preference.vue

@@ -0,0 +1,242 @@
+<template>
+  <view class="preference-page">
+    <!-- 月食品预算 -->
+    <view class="form-section">
+      <text class="label">月食品预算(元)</text>
+      <slider :value="budget" :min="500" :max="10000" :step="100" show-value @change="onBudgetChange" />
+      <text class="hint">当前: {{ budget }}元/月</text>
+    </view>
+
+    <!-- 口味偏好 -->
+    <view class="form-section">
+      <text class="label">口味偏好</text>
+      <checkbox-group @change="onTasteChange">
+        <view class="checkbox-row">
+          <label class="checkbox-label" v-for="item in tasteOptions" :key="item">
+            <checkbox :value="item" :checked="tastePreferences.indexOf(item) >= 0" />
+            <text class="checkbox-text">{{ item }}</text>
+          </label>
+        </view>
+      </checkbox-group>
+    </view>
+
+    <!-- 菜系偏好 -->
+    <view class="form-section">
+      <text class="label">偏好菜系</text>
+      <picker :value="cuisineIndex" :range="cuisineList" @change="onCuisineChange">
+        <view class="picker-value">{{ cuisineList[cuisineIndex] || '请选择' }}</view>
+      </picker>
+    </view>
+
+    <!-- 每日餐数 -->
+    <view class="form-section">
+      <text class="label">每日餐数</text>
+      <picker :value="mealIndex" :range="mealList" @change="onMealCountChange">
+        <view class="picker-value">{{ mealList[mealIndex] || '请选择' }}</view>
+      </picker>
+    </view>
+
+    <!-- 烹饪能力 -->
+    <view class="form-section">
+      <text class="label">烹饪能力</text>
+      <radio-group @change="onCookingChange">
+        <view class="radio-row">
+          <label class="radio-label" v-for="item in cookingOptions" :key="item.value">
+            <radio :value="item.value" :checked="cookingAbility === item.value" />
+            <text class="radio-text">{{ item.label }}</text>
+          </label>
+        </view>
+      </radio-group>
+    </view>
+
+    <button class="save-btn" type="primary" :loading="saving" @click="save" :disabled="saving">
+      {{ saving ? '保存中...' : '保存设置' }}
+    </button>
+  </view>
+</template>
+
+<script>
+import { getNutritionProfile, saveNutritionProfile } from '@/utils/api'
+
+export default {
+  name: 'MealPreference',
+  data() {
+    return {
+      budget: 2000,
+      tastePreferences: [],
+      tasteOptions: ['清淡', '微辣', '甜', '酸', '咸', '鲜'],
+      cuisineIndex: 0,
+      cuisineList: ['中式', '粤菜', '川菜', '西式', '日式', '其他'],
+      mealIndex: 1,
+      mealList: ['2餐', '3餐', '4餐', '5餐'],
+      cookingAbility: 'simple',
+      cookingOptions: [
+        { value: 'simple', label: '简单(只会基础)' },
+        { value: 'medium', label: '一般(能炒家常菜)' },
+        { value: 'advanced', label: '较强(能做大菜)' }
+      ],
+      saving: false
+    }
+  },
+  onLoad() {
+    this.loadCurrentPreferences()
+  },
+  methods: {
+    async loadCurrentPreferences() {
+      try {
+        var res = await getNutritionProfile({})
+        if (res.code === 0 && res.data) {
+          var profile = res.data
+          if (profile.budgetMonthly) {
+            this.budget = profile.budgetMonthly
+          }
+          if (profile.familyTastePreferences) {
+            try {
+              var parsed = JSON.parse(profile.familyTastePreferences)
+              if (Array.isArray(parsed)) {
+                this.tastePreferences = parsed
+              } else {
+                this.tastePreferences = [String(parsed)]
+              }
+            } catch (e) {
+              this.tastePreferences = profile.familyTastePreferences.split(',')
+            }
+          }
+          if (profile.cuisineStyle) {
+            var idx = this.cuisineList.indexOf(profile.cuisineStyle)
+            if (idx >= 0) {
+              this.cuisineIndex = idx
+            }
+          }
+          if (profile.mealCount) {
+            var mealIdx = profile.mealCount - 2
+            if (mealIdx >= 0 && mealIdx < this.mealList.length) {
+              this.mealIndex = mealIdx
+            }
+          }
+          if (profile.cookingAbility) {
+            this.cookingAbility = profile.cookingAbility
+          }
+        }
+      } catch (e) {
+        // 首次加载无偏好,使用默认值
+      }
+    },
+    onBudgetChange(e) {
+      this.budget = e.detail.value
+    },
+    onTasteChange(e) {
+      this.tastePreferences = e.detail.value
+    },
+    onCuisineChange(e) {
+      this.cuisineIndex = e.detail.value
+    },
+    onMealCountChange(e) {
+      this.mealIndex = e.detail.value
+    },
+    onCookingChange(e) {
+      this.cookingAbility = e.detail.value
+    },
+    async save() {
+      this.saving = true
+      try {
+        var params = {
+          budgetMonthly: this.budget,
+          familyTastePreferences: JSON.stringify(this.tastePreferences),
+          cuisineStyle: this.cuisineList[this.cuisineIndex],
+          mealCount: this.mealIndex + 2,
+          cookingAbility: this.cookingAbility
+        }
+        var res = await saveNutritionProfile(params)
+        if (res.code === 0) {
+          uni.showToast({ title: '保存成功', icon: 'success' })
+        } else {
+          uni.showToast({ title: res.message || '保存失败', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: '网络错误', icon: 'none' })
+      } finally {
+        this.saving = false
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.preference-page {
+  min-height: 100vh;
+  background: var(--bg, #FFF7ED);
+  padding: 20rpx 30rpx;
+}
+.form-section {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 30rpx;
+  box-shadow: var(--shadow-md, 0 2rpx 12rpx rgba(0,0,0,0.06));
+  margin-bottom: 20rpx;
+}
+.label {
+  font-size: 28rpx;
+  color: #333;
+  margin-bottom: 16rpx;
+  display: block;
+  font-weight: 500;
+}
+.hint {
+  font-size: 24rpx;
+  color: var(--color-primary, #F97316);
+  margin-top: 10rpx;
+  display: block;
+}
+.picker-value {
+  font-size: 28rpx;
+  color: var(--color-primary, #F97316);
+  padding: 16rpx 20rpx;
+  background: #FFF0E0;
+  border-radius: 8rpx;
+}
+.checkbox-row {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  gap: 16rpx;
+}
+.checkbox-label {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin-right: 16rpx;
+  margin-bottom: 10rpx;
+}
+.checkbox-text {
+  font-size: 28rpx;
+  color: #333;
+  margin-left: 8rpx;
+}
+.radio-row {
+  display: flex;
+  flex-direction: column;
+  gap: 16rpx;
+}
+.radio-label {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin-bottom: 10rpx;
+}
+.radio-text {
+  font-size: 28rpx;
+  color: #333;
+  margin-left: 8rpx;
+}
+.save-btn {
+  margin: 30rpx 0;
+  background: var(--color-primary, #F97316);
+  border: none;
+  border-radius: 12rpx;
+  font-size: 32rpx;
+  height: 88rpx;
+  line-height: 88rpx;
+}
+</style>

+ 304 - 0
cfc-frontend/pages/meal/recipe-detail.vue

@@ -0,0 +1,304 @@
+<template>
+  <view class="recipe-detail">
+    <view v-if="recipe" class="detail-content">
+      <!-- 头部信息 -->
+      <view class="header">
+        <view v-if="recipe.coverImage" class="cover-wrap">
+          <image class="cover" :src="recipe.coverImage" mode="aspectFill" />
+        </view>
+        <text class="title">{{ recipe.name }}</text>
+        <view v-if="recipe.mealType" class="tag-row">
+          <text class="tag">{{ recipe.mealType }}</text>
+          <text v-if="recipe.difficulty" class="tag tag-sec">{{ recipe.difficulty }}</text>
+          <text v-if="recipe.cookTime" class="tag tag-sec">{{ recipe.cookTime }}分钟</text>
+        </view>
+      </view>
+
+      <!-- 营养摘要 -->
+      <view class="section">
+        <text class="section-title">营养信息</text>
+        <view class="nutrition-row">
+          <view class="nutrition-item">
+            <text class="nutrition-val">{{ recipe.totalCalories || 0 }}</text>
+            <text class="nutrition-label">热量(kcal)</text>
+          </view>
+          <view class="nutrition-item">
+            <text class="nutrition-val">{{ recipe.totalProtein || 0 }}</text>
+            <text class="nutrition-label">蛋白(g)</text>
+          </view>
+          <view class="nutrition-item">
+            <text class="nutrition-val">{{ recipe.totalFat || 0 }}</text>
+            <text class="nutrition-label">脂肪(g)</text>
+          </view>
+          <view class="nutrition-item">
+            <text class="nutrition-val">{{ recipe.totalCarbs || 0 }}</text>
+            <text class="nutrition-label">碳水(g)</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 食材列表 -->
+      <view v-if="ingredientList.length > 0" class="section">
+        <text class="section-title">食材</text>
+        <view class="ingredient-item" v-for="(item, idx) in ingredientList" :key="idx">
+          <text class="ingredient-text">{{ item }}</text>
+        </view>
+      </view>
+
+      <!-- 烹饪步骤 -->
+      <view v-if="stepList.length > 0" class="section">
+        <text class="section-title">步骤</text>
+        <view class="step-item" v-for="(step, idx) in stepList" :key="idx">
+          <view class="step-num-wrap">
+            <text class="step-num">{{ idx + 1 }}</text>
+          </view>
+          <text class="step-text">{{ step }}</text>
+        </view>
+      </view>
+
+      <!-- 描述 -->
+      <view v-if="recipe.description" class="section">
+        <text class="section-title">简介</text>
+        <text class="desc-text">{{ recipe.description }}</text>
+      </view>
+    </view>
+
+    <!-- 加载中 -->
+    <view v-if="!recipe && loading" class="empty-state">
+      <text class="loading-text">加载中...</text>
+    </view>
+
+    <!-- 空状态 -->
+    <view v-if="!recipe && !loading" class="empty-state">
+      <text class="empty-icon">🍳</text>
+      <text class="empty-text">食谱信息不存在</text>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getMealRecommend } from '@/utils/api'
+
+export default {
+  name: 'RecipeDetail',
+  data() {
+    return {
+      recipeId: '',
+      recipe: null,
+      ingredientList: [],
+      stepList: [],
+      loading: false
+    }
+  },
+  onLoad(options) {
+    this.recipeId = options.id || ''
+    if (this.recipeId) {
+      this.loadDetail()
+    }
+  },
+  methods: {
+    loadDetail() {
+      this.loading = true
+      // 从缓存中尝试读取推荐结果中的食谱
+      try {
+        var cached = uni.getStorageSync('recommend_result')
+        if (cached) {
+          var data = typeof cached === 'string' ? JSON.parse(cached) : cached
+          var recipes = data.candidateRecipes || []
+          for (var i = 0; i < recipes.length; i++) {
+            if (String(recipes[i].id) === String(this.recipeId)) {
+              this.recipe = recipes[i]
+              this.parseIngredients(this.recipe.ingredients)
+              this.parseSteps(this.recipe.steps)
+              this.loading = false
+              return
+            }
+          }
+        }
+      } catch (e) {
+        // ignore
+      }
+      // 缓存中没有,使用ID占位
+      this.recipe = { id: this.recipeId, name: '食谱详情' }
+      this.loading = false
+    },
+    parseIngredients(str) {
+      if (!str) {
+        this.ingredientList = []
+        return
+      }
+      try {
+        var parsed = typeof str === 'string' ? JSON.parse(str) : str
+        this.ingredientList = Array.isArray(parsed) ? parsed : [String(parsed)]
+      } catch (e) {
+        this.ingredientList = str.split(/[;;,,\n]/).filter(function(s) { return s.trim() })
+      }
+    },
+    parseSteps(str) {
+      if (!str) {
+        this.stepList = []
+        return
+      }
+      try {
+        var parsed = typeof str === 'string' ? JSON.parse(str) : str
+        this.stepList = Array.isArray(parsed) ? parsed : [String(parsed)]
+      } catch (e) {
+        this.stepList = str.split(/[;;\n]/).filter(function(s) { return s.trim() })
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.recipe-detail {
+  min-height: 100vh;
+  background: var(--bg, #FFF7ED);
+  padding: 20rpx 30rpx;
+}
+.header {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 30rpx;
+  box-shadow: var(--shadow-md, 0 2rpx 12rpx rgba(0,0,0,0.06));
+  margin-bottom: 20rpx;
+}
+.cover-wrap {
+  margin-bottom: 20rpx;
+}
+.cover {
+  width: 100%;
+  height: 360rpx;
+  border-radius: 12rpx;
+}
+.title {
+  font-size: 36rpx;
+  font-weight: bold;
+  color: #333;
+  display: block;
+  margin-bottom: 16rpx;
+}
+.tag-row {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  gap: 12rpx;
+}
+.tag {
+  font-size: 22rpx;
+  background: var(--color-primary, #F97316);
+  color: #fff;
+  padding: 6rpx 16rpx;
+  border-radius: 6rpx;
+}
+.tag-sec {
+  background: #FFF0E0;
+  color: var(--color-primary, #F97316);
+}
+.section {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 30rpx;
+  box-shadow: var(--shadow-md, 0 2rpx 12rpx rgba(0,0,0,0.06));
+  margin-bottom: 20rpx;
+}
+.section-title {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #333;
+  margin-bottom: 16rpx;
+  padding-left: 10rpx;
+  border-left: 6rpx solid var(--color-primary, #F97316);
+  display: block;
+}
+.nutrition-row {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  gap: 16rpx;
+}
+.nutrition-item {
+  flex: 1;
+  min-width: 120rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  background: #FFF7ED;
+  border-radius: 12rpx;
+  padding: 16rpx 10rpx;
+}
+.nutrition-val {
+  font-size: 32rpx;
+  color: var(--color-primary, #F97316);
+  font-weight: 600;
+}
+.nutrition-label {
+  font-size: 22rpx;
+  color: #999;
+  margin-top: 6rpx;
+}
+.ingredient-item {
+  padding: 12rpx 0;
+  border-bottom: 1rpx solid #f5f5f5;
+}
+.ingredient-item:last-child {
+  border-bottom: none;
+}
+.ingredient-text {
+  font-size: 28rpx;
+  color: #333;
+}
+.step-item {
+  display: flex;
+  flex-direction: row;
+  margin-bottom: 20rpx;
+}
+.step-num-wrap {
+  flex-shrink: 0;
+  width: 44rpx;
+  height: 44rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 16rpx;
+}
+.step-num {
+  width: 40rpx;
+  height: 40rpx;
+  line-height: 40rpx;
+  text-align: center;
+  background: var(--color-primary, #F97316);
+  color: #fff;
+  border-radius: 50%;
+  font-size: 24rpx;
+}
+.step-text {
+  font-size: 28rpx;
+  color: #333;
+  line-height: 1.6;
+  flex: 1;
+}
+.desc-text {
+  font-size: 28rpx;
+  color: #666;
+  line-height: 1.6;
+}
+.empty-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 120rpx 0;
+}
+.empty-icon {
+  font-size: 80rpx;
+}
+.empty-text {
+  font-size: 28rpx;
+  color: #999;
+  margin-top: 20rpx;
+}
+.loading-text {
+  font-size: 28rpx;
+  color: #999;
+}
+</style>

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

@@ -1326,3 +1326,5 @@ export const replaceFood = (data) => request('/api/meal/replace', 'POST', data)
 export const logMeal = (data) => request('/api/meal/log', 'POST', data)
 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)